Merge views into Match, expand history, and polish the UI

- Match page now holds players/match info, turn stats with a turn
  picker, and the deck lists (My deck · Preview · Opponent); the Deck
  and Turn tabs are gone. Section heights are draggable and remembered.
- History: sortable, resizable columns; deck and commander columns;
  right-click to show only or hide games with a value, with filter
  chips; stats tiles (win rate overall, last 10/100, last 7 days,
  play/draw, average length and turns) that follow the filters.
  Commanders are backfilled for games recorded before they were stored.
- Deck panel shows copies per zone against the deck total (e.g. 2/5).
- Preview can be cleared with ✕, a click on the image, or Esc.
- Customize menu stays open while toggling; Arrange covers deck
  sections and turn stats together.
- Fix squeezed nav buttons and the header pushing controls off-screen.
- Fix known opponent cards being double-counted when also revealed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 12:34:02 -04:00
1 parent 5b3c8c8e63
commit b3325e8ecc
15 files changed
+754 -177

No files matched your search

+22 -23
View File
@@ -3,31 +3,30 @@
A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton. A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton.
It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows: It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows:
Switch views with the buttons in the top bar (or Ctrl+1/2/3): Two views, switched from the top bar (Ctrl+1 / Ctrl+2):
- **Deck** - **Match**, from top to bottom (drag the dividers to resize; sizes are remembered):
- *My deck*: remaining library with draw odds, hand, battlefield, graveyard, exile, sideboard. - *You · Match · Opponent*: life totals, poison/energy and other player counters, commanders,
Between matches, it shows the last deck you submitted. emblems, hand/library/graveyard counts, turn timer and timeouts, land drops used/allowed, who
- *Opponent*: battlefield, stack, graveyard, exile, cards *known* to be in their hand or library was on the play, plus the match panel (turn, phase, game number, Bo3 score and draw count,
(cards you saw that went back into a hidden zone, or were revealed), and every card seen this match. game time).
- *Card preview*: hover a card to see its image from [Scryfall](https://scryfall.com). - *Selected turn · Turn · Whole game*: what happened in a turn for you, your opponent and in
- **Match**: life totals, poison/energy and other player counters, emblems, hand/library/graveyard total (cards drawn, spells cast, lands played, creatures died, life gained/lost, damage,
counts, turn timer and timeouts used, turn/phase, Bo3 score and game time. The deck view fills discards, mills, exiles, counters, tokens, land drops). The middle panel picks the turn
the bottom half. (◀ ▶, or *Live* to follow the current one).
- **Turn**: what happened this turn, for you, your opponent and in total: cards drawn, spells cast, - *My deck · Opponent · Preview*: remaining library with draw odds, your zones; the opponent's
lands played, creatures died, life gained/lost, damage, discards, mills, exiles, counters and board, graveyard, exile, cards known to be in their hand or library, and every card seen this
tokens, plus land drops used/allowed. Use ◀ ▶ to browse earlier turns; the "Whole game" table match; hover a card for its [Scryfall](https://scryfall.com) image.
shows game totals. - **History**: stats (win rate overall, last 10/100 games, last 7 days, on the play/draw, average
- **History**: every finished game with date, event, opponent, both decks' colours, who was on length and turns) and a sortable list of every finished game: date, event, deck, commanders,
the play, the result, turns and length. Click a game to open it in the Deck/Match/Turn views opponent, both decks' colours, who was on the play, result, turns and length. Click a header to
("Back to live" returns). Games from the previous MTGA session (`Player-prev.log`) are imported sort, right-click a cell to show only (or hide) games with that value; the stats follow the
on startup. filters. Click a game to open it in the Match view ("Back to live" returns). Games from the
previous MTGA session (`Player-prev.log`) are imported on startup.
The Match view also shows who was on the play and, in best-of-three, who has been on the draw more. *⚙ Customize* shows or hides the match panels, turn stats, deck sections and the preview.
*Customize → Arrange…* reorders (drag, or ↑/↓) and shows/hides the My deck and Opponent sections
*⚙ Customize* shows or hides the items in the current view. *Customize → Arrange…* lets you and the turn stats. Your choices are remembered.
reorder (drag, or ↑/↓) and show/hide the sections of the My deck and Opponent panels, or the
rows of the turn view. Your choices are remembered.
Card names, mana costs and types come from MTGA's own local card database, so they work offline. Card names, mana costs and types come from MTGA's own local card database, so they work offline.
+23 -14
View File
@@ -415,22 +415,31 @@ class GameState:
return len(z.ids) if z else 0 return len(z.ids) if z else 0
def opp_known_in(self, ztype: str) -> Counter[int]: def opp_known_in(self, ztype: str) -> Counter[int]:
"""Opponent cards we know are in a hidden zone (by tracking identity).""" """Opponent cards we know are in a hidden zone (by tracking identity, or revealed)."""
out: Counter[int] = Counter()
z = self.zone_of_type(ztype, self.opp_seat) z = self.zone_of_type(ztype, self.opp_seat)
if z: if z is None:
for iid in z.ids: return Counter()
if iid in self.known: tracked: Counter[int] = Counter(self.known[iid] for iid in z.ids if iid in self.known)
out[self.known[iid]] += 1
# Cards the opponent revealed from that zone (e.g. "reveal your hand").
if z:
for obj in self.objects.values():
if (obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
and obj.get("grpId")):
out[obj["grpId"]] += 1
if ztype == "ZoneType_Library": if ztype == "ZoneType_Library":
out += self.opp_library_unplaced tracked += self.opp_library_unplaced
return out # Cards the opponent revealed from that zone (e.g. "reveal your hand"). A revealed card is
# often one we already track, so per card take the larger count rather than the sum.
revealed: Counter[int] = Counter(
obj["grpId"] for obj in self.objects.values()
if obj.get("type") == "GameObjectType_RevealedCard" and obj.get("zoneId") == z.zone_id
and obj.get("grpId")
)
out = tracked | revealed
# Never claim to know more cards than the zone holds.
excess = sum(out.values()) - len(z.ids)
for grp in list(revealed):
if excess <= 0:
break
take = min(excess, out[grp] - tracked[grp])
if take > 0:
out[grp] -= take
excess -= take
return +out
def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]: def opp_seen_counts(self, normalize=lambda g: g) -> Counter[int]:
merged: dict[int, set[int]] = {} merged: dict[int, set[int]] = {}
+50 -4
View File
@@ -44,6 +44,8 @@ CREATE TABLE IF NOT EXISTS games (
opp_colors TEXT, opp_colors TEXT,
turns INTEGER, turns INTEGER,
best_of INTEGER, best_of INTEGER,
my_commander TEXT, -- commander name(s), ' / '-separated; NULL = not yet worked out
opp_commander TEXT,
PRIMARY KEY (match_id, game_number) PRIMARY KEY (match_id, game_number)
); );
CREATE TABLE IF NOT EXISTS matches (match_id TEXT PRIMARY KEY, events BLOB NOT NULL); CREATE TABLE IF NOT EXISTS matches (match_id TEXT PRIMARY KEY, events BLOB NOT NULL);
@@ -66,9 +68,15 @@ class GameSummary:
opp_colors: str opp_colors: str
turns: int turns: int
best_of: int best_of: int
my_commander: str | None = ""
opp_commander: str | None = ""
replayable: bool = True replayable: bool = True
GAME_COLUMNS = ("match_id, game_number, started_ms, ended_ms, event_id, opponent, deck_name, on_play, result, "
"my_colors, opp_colors, turns, best_of, my_commander, opp_commander")
# -- event (de)serialisation ------------------------------------------------------ # -- event (de)serialisation ------------------------------------------------------
@@ -118,6 +126,11 @@ class HistoryStore:
self._lock = threading.Lock() self._lock = threading.Lock()
self._conn = sqlite3.connect(path, check_same_thread=False) self._conn = sqlite3.connect(path, check_same_thread=False)
self._conn.executescript(SCHEMA) self._conn.executescript(SCHEMA)
# Columns added after the first release.
existing = {row[1] for row in self._conn.execute("PRAGMA table_info(games)")}
for column in ("my_commander", "opp_commander"):
if column not in existing:
self._conn.execute(f"ALTER TABLE games ADD COLUMN {column} TEXT")
self._conn.commit() self._conn.commit()
def has_match(self, match_id: str) -> bool: def has_match(self, match_id: str) -> bool:
@@ -128,9 +141,10 @@ class HistoryStore:
blob = gzip.compress(json.dumps(events, separators=(",", ":")).encode()) blob = gzip.compress(json.dumps(events, separators=(",", ":")).encode())
with self._lock: with self._lock:
self._conn.executemany( self._conn.executemany(
"INSERT OR IGNORE INTO games VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", f"INSERT OR IGNORE INTO games ({GAME_COLUMNS}) VALUES ({','.join('?' * 15)})",
[(g.match_id, g.game_number, g.started_ms, g.ended_ms, g.event_id, g.opponent, g.deck_name, [(g.match_id, g.game_number, g.started_ms, g.ended_ms, g.event_id, g.opponent, g.deck_name,
g.on_play, g.result, g.my_colors, g.opp_colors, g.turns, g.best_of) for g in games], g.on_play, g.result, g.my_colors, g.opp_colors, g.turns, g.best_of,
g.my_commander, g.opp_commander) for g in games],
) )
self._conn.execute("INSERT OR IGNORE INTO matches VALUES (?, ?)", (match_id, blob)) self._conn.execute("INSERT OR IGNORE INTO matches VALUES (?, ?)", (match_id, blob))
self._prune() self._prune()
@@ -155,10 +169,34 @@ class HistoryStore:
def games(self) -> list[GameSummary]: def games(self) -> list[GameSummary]:
with self._lock: with self._lock:
rows = self._conn.execute( rows = self._conn.execute(
"""SELECT g.*, m.match_id IS NOT NULL FROM games g LEFT JOIN matches m USING (match_id) f"""SELECT {', '.join('g.' + c for c in GAME_COLUMNS.split(', '))}, m.match_id IS NOT NULL
FROM games g LEFT JOIN matches m USING (match_id)
ORDER BY g.started_ms DESC, g.game_number DESC""" ORDER BY g.started_ms DESC, g.game_number DESC"""
).fetchall() ).fetchall()
return [GameSummary(*r[:13], replayable=bool(r[13])) for r in rows] return [GameSummary(*r[:15], replayable=bool(r[15])) for r in rows]
def backfill_commanders(self, cards) -> int:
"""Work out commanders for games recorded before they were stored. Returns rows updated."""
with self._lock:
todo = self._conn.execute(
"""SELECT g.match_id, g.game_number FROM games g JOIN matches m USING (match_id)
WHERE g.my_commander IS NULL OR g.opp_commander IS NULL"""
).fetchall()
updated = 0
events_by_match: dict[str, list[Event]] = {}
for match_id, game_number in todo:
if match_id not in events_by_match:
events_by_match[match_id] = self.events(match_id)
t = replay_game(events_by_match[match_id], game_number, cards)
if t.game is None:
continue
mine, theirs = commander_names(cards, t.game, t.game.my_seat), commander_names(cards, t.game, t.game.opp_seat)
with self._lock:
self._conn.execute("UPDATE games SET my_commander = ?, opp_commander = ? "
"WHERE match_id = ? AND game_number = ?", (mine, theirs, match_id, game_number))
self._conn.commit()
updated += 1
return updated
def events(self, match_id: str) -> list[Event]: def events(self, match_id: str) -> list[Event]:
with self._lock: with self._lock:
@@ -191,6 +229,13 @@ def colors_of(cards, grp_ids) -> str:
return "".join(c for c in "WUBRG" if c in found) return "".join(c for c in "WUBRG" if c in found)
def commander_names(cards, g: GameState, seat: int) -> str:
grps = sorted(g.commanders.get(seat, ()))
if cards is None:
return " / ".join(str(grp) for grp in grps)
return " / ".join(cards.get(grp).name for grp in grps)
def summarize(t: MatchTracker, g: GameState, result: str) -> GameSummary: def summarize(t: MatchTracker, g: GameState, result: str) -> GameSummary:
cards = t.card_lookup cards = t.card_lookup
my_cards = list(g.deck) + list(g.commanders.get(g.my_seat, ())) my_cards = list(g.deck) + list(g.commanders.get(g.my_seat, ()))
@@ -201,6 +246,7 @@ def summarize(t: MatchTracker, g: GameState, result: str) -> GameSummary:
event_id=t.event_id, opponent=t.opponent_name, deck_name=t.deck_name, on_play=on_play, event_id=t.event_id, opponent=t.opponent_name, deck_name=t.deck_name, on_play=on_play,
result=result, my_colors=colors_of(cards, my_cards), opp_colors=colors_of(cards, opp_cards), result=result, my_colors=colors_of(cards, my_cards), opp_colors=colors_of(cards, opp_cards),
turns=g.turn, best_of=g.best_of, turns=g.turn, best_of=g.best_of,
my_commander=commander_names(cards, g, g.my_seat), opp_commander=commander_names(cards, g, g.opp_seat),
) )
+103
View File
@@ -0,0 +1,103 @@
"""Filtering, sorting keys and summary statistics for game history. Pure Python."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .history import GameSummary
WEEK_MS = 7 * 24 * 3600 * 1000
# Fields a game can be filtered on: key -> (column label, value getter, value display).
FILTER_FIELDS: dict[str, tuple[str, Any, Any]] = {
"event": ("Event", lambda g: g.event_id or "", lambda v: (v or "?").replace("_", " ")),
"deck": ("Deck", lambda g: g.deck_name or "", lambda v: v or "(no name)"),
"my_commander": ("Your commander", lambda g: g.my_commander or "", lambda v: v or "(none)"),
"opponent": ("Opponent", lambda g: g.opponent or "", lambda v: v or "?"),
"opp_commander": ("Their commander", lambda g: g.opp_commander or "", lambda v: v or "(none)"),
"game": ("Game", lambda g: "Bo1" if g.best_of != 3 else f"Game {g.game_number} of 3", lambda v: v),
"on_play": ("On the play", lambda g: g.on_play or "",
lambda v: {"me": "you on the play", "opp": "you on the draw"}.get(v, "unknown")),
"my_colors": ("Your colours", lambda g: g.my_colors or "", lambda v: v or "colourless"),
"opp_colors": ("Their colours", lambda g: g.opp_colors or "", lambda v: v or "colourless"),
"result": ("Result", lambda g: g.result or "", lambda v: v or "no result"),
"turns": ("Turns", lambda g: g.turns or 0, lambda v: str(v)),
}
@dataclass(frozen=True)
class Filter:
field: str
value: Any
exclude: bool = False
def matches(self, g: GameSummary) -> bool:
return (FILTER_FIELDS[self.field][1](g) == self.value) != self.exclude
@property
def label(self) -> str:
name, _get, show = FILTER_FIELDS[self.field]
return f"{name} {'≠' if self.exclude else '='} {show(self.value)}"
def apply_filters(games: list[GameSummary], filters: list[Filter]) -> list[GameSummary]:
return [g for g in games if all(f.matches(g) for f in filters)]
def length_seconds(g: GameSummary) -> int | None:
if not g.started_ms or not g.ended_ms:
return None
return max(0, (g.ended_ms - g.started_ms) // 1000)
@dataclass
class Record:
wins: int = 0
losses: int = 0
@property
def games(self) -> int:
return self.wins + self.losses
@property
def rate(self) -> float | None:
return self.wins / self.games if self.games else None
def record(games: list[GameSummary]) -> Record:
return Record(sum(g.result == "Won" for g in games), sum(g.result == "Lost" for g in games))
@dataclass
class HistoryStats:
games: int
overall: Record
last_10: Record
last_100: Record
week_games: int
week: Record
on_play: Record
on_draw: Record
avg_length_s: float | None
avg_turns: float | None
def compute_stats(games: list[GameSummary], now_ms: int) -> HistoryStats:
newest_first = sorted(games, key=lambda g: g.started_ms or 0, reverse=True)
decided = [g for g in newest_first if g.result in ("Won", "Lost")]
week = [g for g in newest_first if g.started_ms and now_ms - g.started_ms <= WEEK_MS]
lengths = [s for g in games if (s := length_seconds(g)) is not None]
turns = [g.turns for g in games if g.turns]
return HistoryStats(
games=len(games),
overall=record(decided),
last_10=record(decided[:10]),
last_100=record(decided[:100]),
week_games=len(week),
week=record(week),
on_play=record([g for g in games if g.on_play == "me"]),
on_draw=record([g for g in games if g.on_play == "opp"]),
avg_length_s=sum(lengths) / len(lengths) if lengths else None,
avg_turns=sum(turns) / len(turns) if turns else None,
)
+9 -2
View File
@@ -40,6 +40,13 @@ class Row:
count: int = 1 count: int = 1
note: str = "" note: str = ""
dim: bool = False dim: bool = False
total: int | None = None # copies in the deck; shown as "count/total" when set
@property
def count_text(self) -> str:
if self.total:
return f"{self.count}/{self.total}"
return f"{self.count}×" if self.count else ""
@dataclass @dataclass
@@ -144,7 +151,7 @@ class CardList(QTreeWidget):
def set_sections(self, sections: list[Section]) -> None: def set_sections(self, sections: list[Section]) -> None:
# Skip rebuilding (and losing scroll position) when nothing changed. # Skip rebuilding (and losing scroll position) when nothing changed.
signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.note, r.dim) for r in s.rows]) for s in sections] signature = [(s.key, s.title, [(r.info.grp_id, r.count, r.total, r.note, r.dim) for r in s.rows]) for s in sections]
if signature == self._last_render: if signature == self._last_render:
return return
self._last_render = signature self._last_render = signature
@@ -169,7 +176,7 @@ class CardList(QTreeWidget):
self.addTopLevelItem(top) self.addTopLevelItem(top)
top.setFirstColumnSpanned(True) top.setFirstColumnSpanned(True)
for row in sorted(sec.rows, key=sort_key): for row in sorted(sec.rows, key=sort_key):
it = QTreeWidgetItem([f"{row.count}×" if row.count else "", "", row.info.name, row.note]) it = QTreeWidgetItem([row.count_text, "", row.info.name, row.note])
it.setData(0, GRP_ROLE, row.info.grp_id) it.setData(0, GRP_ROLE, row.info.grp_id)
it.setData(COL_MANA, MANA_ROLE, list(row.info.mana)) it.setData(COL_MANA, MANA_ROLE, list(row.info.mana))
it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}") it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}")
+22 -9
View File
@@ -71,20 +71,26 @@ class DeckView(QWidget):
self.preview = CardPreview(cards, images) self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card) self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card) self.opp.hovered.connect(self.preview.show_card)
self.preview.cleared.connect(self._clear_selection)
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(card_frame("My deck", self.mine)) split.addWidget(card_frame("My deck", self.mine))
split.addWidget(card_frame("Opponent", self.opp))
split.addWidget(card_frame("Preview", self.preview)) split.addWidget(card_frame("Preview", self.preview))
split.addWidget(card_frame("Opponent", self.opp))
split.setStretchFactor(0, 3) split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3) split.setStretchFactor(1, 2)
split.setStretchFactor(2, 2) split.setStretchFactor(2, 3)
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split) lay.addWidget(split)
def _clear_selection(self) -> None:
for lst in (self.mine, self.opp):
lst.setCurrentItem(None)
lst.clearSelection()
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]: def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
return [ return [
Row(info=self.cards.get(grp), count=n, note=note(grp, n) if note else "", dim=dim) Row(info=self.cards.get(grp), count=n, note=note(grp, n) if note else "", dim=dim)
@@ -131,19 +137,26 @@ class DeckView(QWidget):
# Every card in the deck; ones with no copies left stay listed, greyed out. # Every card in the deck; ones with no copies left stay listed, greyed out.
lib_rows = [ lib_rows = [
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n) Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n, total=g.deck[grp])
for grp in g.deck for grp in g.deck
for n in [remaining.get(grp, 0)] for n in [remaining.get(grp, 0)]
] ]
def zone_rows(counts: Counter[int]) -> list[Row]:
"""Rows showing "in this zone / copies in the deck" for cards from my deck list."""
rows = self._rows(counts)
for row in rows:
row.total = g.deck.get(t.normalize(row.info.grp_id)) or None
return rows
sections = self._commander_section(g, me) + [ sections = self._commander_section(g, me) + [
Section("library", f"Library ({lib_size})", lib_rows), Section("library", f"Library ({lib_size})", lib_rows),
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))), Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", zone_rows(g.cards_in("ZoneType_Hand", owner=me))),
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=me))), Section("stack", "Stack", zone_rows(g.cards_in("ZoneType_Stack", owner=me))),
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))), Section("battlefield", "Battlefield", zone_rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})", Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))), zone_rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))), Section("exile", "Exile", zone_rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard), Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True), collapsed_by_default=True),
] ]
+276 -39
View File
@@ -1,16 +1,21 @@
"""History view: games played; click one to open it in the deck/match/turn views.""" """History view: stats, sortable/filterable list of games; click one to open it."""
from __future__ import annotations from __future__ import annotations
import time
from datetime import datetime from datetime import datetime
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import QPoint, QSettings, Qt, Signal
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QFrame,
QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView, QHeaderView,
QLabel, QLabel,
QMenu,
QPushButton,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QVBoxLayout, QVBoxLayout,
@@ -18,13 +23,26 @@ from PySide6.QtWidgets import (
) )
from ..history import GameSummary from ..history import GameSummary
from ..history_stats import FILTER_FIELDS, Filter, Record, apply_filters, compute_stats, length_seconds
from . import theme from . import theme
from .card_list import MANA_ROLE, ManaDelegate from .card_list import MANA_ROLE, ManaDelegate
from .deck_view import card_frame from .deck_view import card_frame
COLUMNS = ["When", "Event", "Opponent", "Game", "On the play", "You", "Them", "Result", "Turns", "Length"] COLUMNS = ["When", "Event", "Deck", "Your commander", "Opponent", "Their commander", "Game", "On the play",
COL_PLAY, COL_MINE, COL_THEIRS, COL_RESULT = 4, 5, 6, 7 "You", "Them", "Result", "Turns", "Length"]
(COL_WHEN, COL_EVENT, COL_DECK, COL_MY_CMDR, COL_OPPONENT, COL_OPP_CMDR, COL_GAME, COL_PLAY, COL_MINE,
COL_THEIRS, COL_RESULT, COL_TURNS, COL_LENGTH) = range(len(COLUMNS))
COLUMN_STATE_KEY = "history/columns-v2" # bump when the column set changes
SORT_KEY = "history/sort"
KEY_ROLE = Qt.ItemDataRole.UserRole + 20 KEY_ROLE = Qt.ItemDataRole.UserRole + 20
SORT_ROLE = Qt.ItemDataRole.UserRole + 21
# Right-click filtering: column -> field in history_stats.FILTER_FIELDS (not offered for When/Length).
COLUMN_FIELDS = {
COL_EVENT: "event", COL_DECK: "deck", COL_MY_CMDR: "my_commander", COL_OPPONENT: "opponent",
COL_OPP_CMDR: "opp_commander", COL_GAME: "game", COL_PLAY: "on_play", COL_MINE: "my_colors",
COL_THEIRS: "opp_colors", COL_RESULT: "result", COL_TURNS: "turns",
}
def fmt_when(ms: int | None) -> str: def fmt_when(ms: int | None) -> str:
@@ -37,18 +55,98 @@ def fmt_when(ms: int | None) -> str:
return dt.strftime("%b %d %H:%M") return dt.strftime("%b %d %H:%M")
def _fmt_length(g: GameSummary) -> str: def fmt_seconds(sec: float | None) -> str:
if not g.started_ms or not g.ended_ms: if sec is None:
return "" return ""
sec = max(0, (g.ended_ms - g.started_ms) // 1000) sec = int(sec)
return f"{sec // 60}:{sec % 60:02}" return f"{sec // 60}:{sec % 60:02}"
def sort_key(g: GameSummary, col: int):
"""Comparable key for a column (all keys in a column share a type)."""
if col == COL_WHEN:
return g.started_ms or 0
if col == COL_GAME:
return (g.best_of, g.game_number)
if col in (COL_MINE, COL_THEIRS):
colors = g.my_colors if col == COL_MINE else g.opp_colors
return (len(colors or ""), tuple("WUBRG".index(c) for c in colors or ""))
if col == COL_TURNS:
return g.turns or 0
if col == COL_LENGTH:
return length_seconds(g) or 0
field = COLUMN_FIELDS.get(col)
return str(FILTER_FIELDS[field][1](g)).lower() if field else ""
class SortItem(QTableWidgetItem):
def __lt__(self, other: QTableWidgetItem) -> bool:
return self.data(SORT_ROLE) < other.data(SORT_ROLE)
class StatTile(QFrame):
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setObjectName("card")
self.title = QLabel(title)
self.title.setObjectName("muted")
self.value = QLabel()
self.value.setObjectName("statValue")
self.sub = QLabel()
self.sub.setObjectName("muted")
lay = QVBoxLayout(self)
lay.setContentsMargins(12, 8, 12, 10)
lay.setSpacing(2)
lay.addWidget(self.title)
lay.addWidget(self.value)
lay.addWidget(self.sub)
def show_record(self, rec: Record, extra: str = "") -> None:
if rec.rate is None:
self.value.setText("–")
else:
color = theme.MINT_TEXT if rec.rate >= 0.5 else theme.DANGER
self.value.setText(f"<span style='color:{color}'>{rec.rate:.0%}</span>")
self.sub.setText(" · ".join(x for x in (f"{rec.wins}–{rec.losses}", extra) if x))
def show_value(self, value: str, sub: str = "") -> None:
self.value.setText(value or "–")
self.sub.setText(sub)
class HistoryView(QWidget): class HistoryView(QWidget):
open_game = Signal(str, int) # match_id, game_number open_game = Signal(str, int) # match_id, game_number
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._settings = QSettings("mtga-assistant", "mtga-assistant")
self._columns_ready = False
self.all_games: list[GameSummary] = []
self.shown: list[GameSummary] = []
self.filters: list[Filter] = []
# Stats tiles
self.tiles = {
"overall": StatTile("Win rate"),
"last10": StatTile("Last 10 games"),
"last100": StatTile("Last 100 games"),
"week": StatTile("Last 7 days"),
"play": StatTile("On the play"),
"draw": StatTile("On the draw"),
"length": StatTile("Avg game length"),
"turns": StatTile("Avg turns"),
}
tiles = QGridLayout()
tiles.setSpacing(8)
for i, tile in enumerate(self.tiles.values()):
tiles.addWidget(tile, 0, i)
# Filter chips
self.chips_bar = QWidget()
self.chips = QHBoxLayout(self.chips_bar)
self.chips.setContentsMargins(0, 0, 0, 0)
# Table
self.table = QTableWidget(0, len(COLUMNS)) self.table = QTableWidget(0, len(COLUMNS))
self.table.setHorizontalHeaderLabels(COLUMNS) self.table.setHorizontalHeaderLabels(COLUMNS)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
@@ -59,69 +157,200 @@ class HistoryView(QWidget):
self.table.setMouseTracking(True) self.table.setMouseTracking(True)
self.table.verticalHeader().hide() self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(36) self.table.verticalHeader().setDefaultSectionSize(36)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._context_menu)
hdr = self.table.horizontalHeader() hdr = self.table.horizontalHeader()
hdr.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # User-resizable columns; widths are remembered (see _restore_or_size_columns).
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
hdr.setStretchLastSection(False)
hdr.setMinimumSectionSize(40)
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
for col in (COL_MINE, COL_THEIRS): hdr.setSortIndicatorShown(True)
self.table.setItemDelegateForColumn(col, ManaDelegate(self.table)) hdr.setSectionsClickable(True)
col, order = self._saved_sort()
hdr.setSortIndicator(col, order)
hdr.sectionResized.connect(self._save_columns)
hdr.sortIndicatorChanged.connect(self._save_sort)
for c in (COL_MINE, COL_THEIRS):
self.table.setItemDelegateForColumn(c, ManaDelegate(self.table))
self.table.cellClicked.connect(self._clicked) self.table.cellClicked.connect(self._clicked)
self.table.cellActivated.connect(self._clicked) self.table.cellActivated.connect(self._clicked)
self.summary = QLabel() self.empty = QLabel()
self.summary.setObjectName("muted")
self.empty = QLabel("No games recorded yet. Finished matches show up here.")
self.empty.setObjectName("muted") self.empty.setObjectName("muted")
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
top = QHBoxLayout() hint = QLabel("Click a game to open it · right-click to filter · click a header to sort")
top.addWidget(self.summary, 1)
hint = QLabel("Click a game to open it")
hint.setObjectName("muted") hint.setObjectName("muted")
top = QHBoxLayout()
top.addWidget(self.chips_bar, 1)
top.addWidget(hint) top.addWidget(hint)
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addLayout(tiles)
lay.addLayout(top) lay.addLayout(top)
lay.addWidget(card_frame("Games played", self.table), 1) lay.addWidget(card_frame("Games played", self.table), 1)
lay.addWidget(self.empty) lay.addWidget(self.empty)
def _clicked(self, row: int, _col: int) -> None: # -- persistence -----------------------------------------------------------------
def _saved_sort(self) -> tuple[int, Qt.SortOrder]:
saved = self._settings.value(SORT_KEY)
try:
col, order = (int(x) for x in str(saved).split(":"))
if 0 <= col < len(COLUMNS):
return col, Qt.SortOrder(order)
except (TypeError, ValueError):
pass
return COL_WHEN, Qt.SortOrder.DescendingOrder
def _save_sort(self, col: int, order: Qt.SortOrder) -> None:
self._settings.setValue(SORT_KEY, f"{col}:{order.value}")
def _restore_or_size_columns(self) -> None:
"""First time there are rows: restore saved widths, or size columns to fit their contents."""
self._columns_ready = True
saved = self._settings.value(COLUMN_STATE_KEY)
hdr = self.table.horizontalHeader()
sort = (hdr.sortIndicatorSection(), hdr.sortIndicatorOrder())
if saved is not None and hdr.restoreState(saved):
hdr.setSortIndicator(*sort) # the separately saved sort wins
return
self.table.resizeColumnsToContents()
pips = 5 * (ManaDelegate.PIP + 2) + 16 # room for all five colours
for col in (COL_MINE, COL_THEIRS):
self.table.setColumnWidth(col, max(self.table.columnWidth(col), pips))
self.table.setColumnWidth(COL_OPPONENT, max(self.table.columnWidth(COL_OPPONENT) + 24, 180))
for col in range(len(COLUMNS)):
# Breathing room (and the sort arrow), but don't let long names take over the table.
self.table.setColumnWidth(col, min(self.table.columnWidth(col) + 24, 260))
self._save_columns()
def _save_columns(self, *_args) -> None:
if self._columns_ready:
self._settings.setValue(COLUMN_STATE_KEY, self.table.horizontalHeader().saveState())
# -- interaction --------------------------------------------------------------------
def _game_at_row(self, row: int) -> GameSummary | None:
item = self.table.item(row, 0) item = self.table.item(row, 0)
if item is None: if item is None:
return return None
match_id, game_number, replayable = item.data(KEY_ROLE) match_id, game_number = item.data(KEY_ROLE)
if replayable: return next((g for g in self.shown if g.match_id == match_id and g.game_number == game_number), None)
self.open_game.emit(match_id, game_number)
def _clicked(self, row: int, _col: int) -> None:
g = self._game_at_row(row)
if g is not None and g.replayable:
self.open_game.emit(g.match_id, g.game_number)
def _context_menu(self, pos: QPoint) -> None:
index = self.table.indexAt(pos)
menu = QMenu(self)
g = self._game_at_row(index.row()) if index.isValid() else None
field = COLUMN_FIELDS.get(index.column()) if index.isValid() else None
if g is not None and field is not None:
value = FILTER_FIELDS[field][1](g)
only, hide = Filter(field, value), Filter(field, value, exclude=True)
menu.addAction(f"Only show: {only.label}").triggered.connect(lambda: self.add_filter(only))
menu.addAction(f"Hide: {only.label}").triggered.connect(lambda: self.add_filter(hide))
if self.filters:
if not menu.isEmpty():
menu.addSeparator()
menu.addAction("Clear all filters").triggered.connect(self.clear_filters)
if not menu.isEmpty():
menu.exec(self.table.viewport().mapToGlobal(pos))
def add_filter(self, f: Filter) -> None:
# A new filter on a field replaces an existing "only show" on the same field.
self.filters = [x for x in self.filters if not (x.field == f.field and not x.exclude and not f.exclude)]
if f not in self.filters:
self.filters.append(f)
self._refresh()
def remove_filter(self, f: Filter) -> None:
self.filters = [x for x in self.filters if x != f]
self._refresh()
def clear_filters(self) -> None:
self.filters = []
self._refresh()
# -- rendering -----------------------------------------------------------------------
def set_games(self, games: list[GameSummary]) -> None: def set_games(self, games: list[GameSummary]) -> None:
self.empty.setVisible(not games) self.all_games = games
wins = sum(g.result == "Won" for g in games) self._refresh()
losses = sum(g.result == "Lost" for g in games)
on_play = [g for g in games if g.on_play]
play_wins = sum(g.result == "Won" for g in on_play if g.on_play == "me")
draw_wins = sum(g.result == "Won" for g in on_play if g.on_play == "opp")
n_play = sum(g.on_play == "me" for g in on_play)
n_draw = len(on_play) - n_play
self.summary.setText(
f"{len(games)} games · {wins}–{losses}"
+ (f" · on the play {play_wins}/{n_play} won" if n_play else "")
+ (f" · on the draw {draw_wins}/{n_draw} won" if n_draw else "")
)
def _refresh(self) -> None:
self.shown = apply_filters(self.all_games, self.filters)
self._render_chips()
self._render_stats()
self._render_table()
if not self.all_games:
self.empty.setText("No games recorded yet. Finished matches show up here.")
else:
self.empty.setText("No games match the filters.")
self.empty.setVisible(not self.shown)
def _render_chips(self) -> None:
while self.chips.count():
w = self.chips.takeAt(0).widget()
if w is not None:
w.hide() # deleteLater is deferred; don't leave it drawn under the new chips
w.deleteLater()
if not self.filters:
label = QLabel(f"All games ({len(self.all_games)})")
label.setObjectName("muted")
self.chips.addWidget(label)
else:
label = QLabel(f"Filtered: {len(self.shown)} of {len(self.all_games)} games")
label.setObjectName("cardTitle")
self.chips.addWidget(label)
for f in self.filters:
chip = QPushButton(f"{f.label} ✕")
chip.setObjectName("chip")
chip.setToolTip("Remove this filter")
chip.clicked.connect(lambda _=False, f=f: self.remove_filter(f))
self.chips.addWidget(chip)
clear = QPushButton("Clear all")
clear.clicked.connect(self.clear_filters)
self.chips.addWidget(clear)
self.chips.addStretch(1)
def _render_stats(self) -> None:
s = compute_stats(self.shown, int(time.time() * 1000))
t = self.tiles
t["overall"].show_record(s.overall, f"{s.games} games")
t["last10"].show_record(s.last_10)
t["last100"].show_record(s.last_100)
t["week"].show_record(s.week, f"{s.week_games} played")
t["play"].show_record(s.on_play)
t["draw"].show_record(s.on_draw)
t["length"].show_value(fmt_seconds(s.avg_length_s), "per game")
t["turns"].show_value(f"{s.avg_turns:.1f}" if s.avg_turns is not None else "", "per game")
def _render_table(self) -> None:
games = self.shown
self.table.setSortingEnabled(False) # don't re-sort while filling
self.table.setRowCount(len(games)) self.table.setRowCount(len(games))
for r, g in enumerate(games): for r, g in enumerate(games):
game_label = f"{g.game_number} of 3" if g.best_of == 3 else "Bo1" game_label = f"{g.game_number} of 3" if g.best_of == 3 else "Bo1"
values = [fmt_when(g.started_ms), (g.event_id or "").replace("_", " "), g.opponent or "?", values = [fmt_when(g.started_ms), (g.event_id or "").replace("_", " "), g.deck_name or "",
game_label, "", "", "", g.result or "–", str(g.turns or ""), _fmt_length(g)] g.my_commander or "", g.opponent or "?", g.opp_commander or "",
game_label, "", "", "", g.result or "–", str(g.turns or ""), fmt_seconds(length_seconds(g))]
for c, v in enumerate(values): for c, v in enumerate(values):
item = QTableWidgetItem(v) item = SortItem(v)
item.setData(SORT_ROLE, sort_key(g, c))
item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if v:
item.setToolTip(v) # full text when the column is narrower than it
if not g.replayable: if not g.replayable:
item.setForeground(QColor(theme.MUTED)) item.setForeground(QColor(theme.MUTED))
item.setToolTip("Details for this game were pruned from the history cache") item.setToolTip("Details for this game were pruned from the history cache")
self.table.setItem(r, c, item) self.table.setItem(r, c, item)
self.table.item(r, 0).setData(KEY_ROLE, (g.match_id, g.game_number, g.replayable)) self.table.item(r, 0).setData(KEY_ROLE, (g.match_id, g.game_number))
play = self.table.item(r, COL_PLAY) play = self.table.item(r, COL_PLAY)
if g.on_play == "me": if g.on_play == "me":
@@ -145,3 +374,11 @@ class HistoryView(QWidget):
res.setForeground(QColor(theme.MINT_TEXT)) res.setForeground(QColor(theme.MINT_TEXT))
elif g.result == "Lost": elif g.result == "Lost":
res.setForeground(QColor(theme.DANGER)) res.setForeground(QColor(theme.DANGER))
self.table.setSortingEnabled(True) # sorts by the current header indicator
if games and not self._columns_ready:
self._restore_or_size_columns()
# Commander columns only matter if some shown game had commanders (Brawl).
has_commanders = any(g.my_commander or g.opp_commander for g in self.shown)
for col in (COL_MY_CMDR, COL_OPP_CMDR):
self.table.setColumnHidden(col, not has_commanders)
+48 -44
View File
@@ -6,7 +6,7 @@ import time
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QKeySequence from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QButtonGroup, QButtonGroup,
QFileDialog, QFileDialog,
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
QMenu, QMenu,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSizePolicy,
QStackedWidget, QStackedWidget,
QToolButton, QToolButton,
QVBoxLayout, QVBoxLayout,
@@ -38,8 +39,8 @@ from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
REFRESH_MS = 150 REFRESH_MS = 150
DECK_TOGGLES = [("preview", "Card preview pane")] DECK_TOGGLES = [("preview", "Card preview pane")]
VIEWS = ["Deck", "Match", "Turn", "History"] VIEWS = ["Match", "History"]
HISTORY = 3 MATCH, HISTORY = 0, 1
class HistoryImport(QThread): class HistoryImport(QThread):
@@ -52,17 +53,21 @@ class HistoryImport(QThread):
self.path, self.store, self.cards = path, store, cards self.path, self.store, self.cards = path, store, cards
def run(self) -> None: def run(self) -> None:
# Own card DB connection: this runs off the GUI thread.
cards = CardDB(self.cards.path)
saved = 0
try: try:
st = self.path.stat() st = self.path.stat()
except OSError: except OSError:
return st = None
if st is not None:
stamp = f"{st.st_size}:{st.st_mtime_ns}" stamp = f"{st.st_size}:{st.st_mtime_ns}"
key = f"imported:{self.path}" key = f"imported:{self.path}"
if self.store.meta(key) == stamp: if self.store.meta(key) != stamp:
return saved = import_log(self.path, self.store, cards)
# Own card DB connection: this runs off the GUI thread.
saved = import_log(self.path, self.store, CardDB(self.cards.path))
self.store.set_meta(key, stamp) self.store.set_meta(key, stamp)
# Games recorded before commanders were stored.
saved += self.store.backfill_commanders(cards)
self.done.emit(saved) self.done.emit(saved)
@@ -97,16 +102,20 @@ class MainWindow(QMainWindow):
# Views. One DeckView is shared by the Deck page and the bottom half of the Match page. # Views. One DeckView is shared by the Deck page and the bottom half of the Match page.
self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections) self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections)
self.deck_page = QWidget()
self.deck_page_layout = QVBoxLayout(self.deck_page)
self.deck_page_layout.setContentsMargins(0, 0, 0, 0)
self.match_view = MatchView(cards, self.toggles[1])
self.match_view.hovered.connect(self.deck_view.preview.show_card)
self.turn_view = TurnView(self.toggles[2]) self.turn_view = TurnView(self.toggles[2])
self.match_view = MatchView(cards, self.toggles[1], self.turn_view, self.deck_view)
self.match_view.hovered.connect(self.deck_view.preview.show_card)
if (state := self._settings.value("match/split")) is not None:
self.match_view.split.restoreState(state)
else:
# Players / turn stats / deck lists: enough room for a useful number of stat rows.
self.match_view.split.setSizes([250, 360, 560])
self.match_view.split.splitterMoved.connect(
lambda *_: self._settings.setValue("match/split", self.match_view.split.saveState()))
self.history_view = HistoryView() self.history_view = HistoryView()
self.history_view.open_game.connect(self.open_past_game) self.history_view.open_game.connect(self.open_past_game)
self.stack = QStackedWidget() self.stack = QStackedWidget()
for page in (self.deck_page, self.match_view, self.turn_view, self.history_view): for page in (self.match_view, self.history_view):
self.stack.addWidget(page) self.stack.addWidget(page)
# Top bar # Top bar
@@ -120,12 +129,19 @@ class MainWindow(QMainWindow):
btn.setCheckable(True) btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})") btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
# The selected button's label is bold; reserve room for that so it isn't squeezed.
bold = QFont(btn.font())
bold.setBold(True)
btn.setMinimumWidth(QFontMetrics(bold).horizontalAdvance(name) + 2 * theme.NAV_PADDING_X + 4)
btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.nav.addButton(btn, i) self.nav.addButton(btn, i)
bar.addWidget(btn) bar.addWidget(btn)
self.nav.idClicked.connect(self.show_view) self.nav.idClicked.connect(self.show_view)
bar.addSpacing(16) bar.addSpacing(16)
self.header = QLabel("Waiting for a match…") self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText) self.header.setTextFormat(Qt.TextFormat.RichText)
# Let long headers be cut off rather than squeezing the buttons around them.
self.header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
bar.addWidget(self.header, 1) bar.addWidget(self.header, 1)
self.customize = QToolButton() self.customize = QToolButton()
self.customize.setText("⚙ Customize") self.customize.setText("⚙ Customize")
@@ -188,7 +204,7 @@ class MainWindow(QMainWindow):
self.resize(1400, 880) self.resize(1400, 880)
self.history_view.set_games(history.games()) self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view", 0, type=int))) self.show_view(int(self._settings.value("view2", MATCH, type=int)))
if log_path is not None: if log_path is not None:
self.start_log(log_path, replay_rate) self.start_log(log_path, replay_rate)
else: else:
@@ -213,49 +229,38 @@ class MainWindow(QMainWindow):
quit_act.setShortcut(QKeySequence.StandardKey.Quit) quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close) quit_act.triggered.connect(self.close)
self.addAction(quit_act) # keep the shortcut active while the menu is closed self.addAction(quit_act) # keep the shortcut active while the menu is closed
unpreview = QAction("Stop previewing", self)
unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape))
unpreview.triggered.connect(self.deck_view.preview.clear)
self.addAction(unpreview)
menu.addAction(quit_act) menu.addAction(quit_act)
btn.setMenu(menu) btn.setMenu(menu)
return btn return btn
def _arrangeable(self) -> list[Toggles]: def _arrangeable(self) -> list[Toggles]:
"""Toggle groups whose order can be arranged in the current view (deck view in match view too).""" return [self.my_sections, self.opp_sections, self.toggles[2]]
index = self.stack.currentIndex()
if index in (0, 1):
return [self.my_sections, self.opp_sections]
return [self.toggles[2]]
def _fill_customize(self) -> None: def _fill_customize(self) -> None:
menu = self.customize_menu menu = self.customize_menu
menu.clear() menu.clear()
index = self.stack.currentIndex() self.toggles[1].fill_menu(menu) # match panels
if index == 2: for title, tg in (("Turn stats", self.toggles[2]), ("My deck sections", self.my_sections),
self.toggles[2].fill_menu(menu) ("Opponent sections", self.opp_sections), ("Preview", self.toggles[0])):
else: sub = StayOpenMenu(title, menu)
self.toggles[index].fill_menu(menu)
for tg in (self.my_sections, self.opp_sections):
sub = StayOpenMenu(f"{tg.title} sections", menu)
tg.fill_menu(sub) tg.fill_menu(sub)
menu.addMenu(sub) menu.addMenu(sub)
menu.addSeparator() menu.addSeparator()
arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…") menu.addAction("Arrange sections and stats…").triggered.connect(self._open_arrange)
arrange.triggered.connect(self._open_arrange)
def _open_arrange(self) -> None: def _open_arrange(self) -> None:
index = self.stack.currentIndex() ArrangeDialog(self._arrangeable(), "Arrange sections and stats", self).exec()
title = "Arrange turn stats" if index == 2 else "Arrange deck sections"
ArrangeDialog(self._arrangeable(), title, self).exec()
def show_view(self, index: int) -> None: def show_view(self, index: int) -> None:
index = max(0, min(len(VIEWS) - 1, index)) index = max(0, min(len(VIEWS) - 1, index))
self.customize.setEnabled(index != HISTORY) self.customize.setEnabled(index == MATCH)
# Move the shared deck view to wherever it is needed.
if index == 1:
self.match_view.deck_slot_layout.addWidget(self.deck_view)
else:
self.deck_page_layout.addWidget(self.deck_view)
self.stack.setCurrentIndex(index) self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True) self.nav.button(index).setChecked(True)
self._settings.setValue("view", index) self._settings.setValue("view2", index)
self._force_refresh() self._force_refresh()
# -- menu actions ---------------------------------------------------------- # -- menu actions ----------------------------------------------------------
@@ -326,7 +331,7 @@ class MainWindow(QMainWindow):
self.past_label.setText(text) self.past_label.setText(text)
self.past_bar.show() self.past_bar.show()
self.turn_view.selected = None self.turn_view.selected = None
self.show_view(0) self.show_view(MATCH)
def close_past_game(self) -> None: def close_past_game(self) -> None:
self.past = None self.past = None
@@ -364,7 +369,7 @@ class MainWindow(QMainWindow):
self.refresh() self.refresh()
def _tick(self) -> None: def _tick(self) -> None:
if self.stack.currentIndex() == 1: if self.stack.currentIndex() == MATCH:
self.match_view.tick(self.shown_tracker, self.now_ms()) self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None: def refresh(self) -> None:
@@ -376,11 +381,9 @@ class MainWindow(QMainWindow):
self._rendered[index] = key self._rendered[index] = key
self._render_header(t) self._render_header(t)
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview")) self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
if index in (0, 1): if index == MATCH:
self.deck_view.render(t) self.deck_view.render(t)
if index == 1:
self.match_view.render(t, self.now_ms()) self.match_view.render(t, self.now_ms())
elif index == 2:
self.turn_view.render(t) self.turn_view.render(t)
def _render_header(self, t: MatchTracker) -> None: def _render_header(self, t: MatchTracker) -> None:
@@ -397,6 +400,7 @@ class MainWindow(QMainWindow):
if not t.in_match and t.last_result: if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>") parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts)) self.header.setText(" · ".join(parts))
self.header.setToolTip(" · ".join(parts))
messages = [] messages = []
if not self.cards.available: if not self.cards.available:
+15 -16
View File
@@ -189,7 +189,7 @@ class PlayerCard(QFrame):
class MatchView(QWidget): class MatchView(QWidget):
hovered = Signal(int) hovered = Signal(int)
def __init__(self, cards: CardDB, toggles: Toggles, parent=None): def __init__(self, cards: CardDB, toggles: Toggles, turn_view: QWidget, deck_view: QWidget, parent=None):
super().__init__(parent) super().__init__(parent)
self.cards = cards self.cards = cards
self.toggles = toggles self.toggles = toggles
@@ -200,7 +200,7 @@ class MatchView(QWidget):
self.info = QFrame() self.info = QFrame()
self.info.setObjectName("card") self.info.setObjectName("card")
self.turn_label = _label(name="bigNumber") self.turn_label = _label(name="statValue")
self.turn_caption = _label(name="muted") self.turn_caption = _label(name="muted")
self.phase = _label() self.phase = _label()
self.score = _label() self.score = _label()
@@ -209,7 +209,7 @@ class MatchView(QWidget):
self.duration = _label(name="muted") self.duration = _label(name="muted")
grid = QGridLayout(self.info) grid = QGridLayout(self.info)
grid.setContentsMargins(14, 10, 14, 12) grid.setContentsMargins(14, 10, 14, 12)
for row, w in enumerate([_label("Turn", "cardTitle"), self.turn_label, self.turn_caption, for row, w in enumerate([_label("Match", "cardTitle"), self.turn_label, self.turn_caption,
self.phase, self.score, self.draws, self.duration]): self.phase, self.score, self.draws, self.duration]):
grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter) grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter)
grid.setRowStretch(7, 1) grid.setRowStretch(7, 1)
@@ -221,19 +221,18 @@ class MatchView(QWidget):
top_lay.addWidget(self.info, 2) top_lay.addWidget(self.info, 2)
top_lay.addWidget(self.opp, 3) top_lay.addWidget(self.opp, 3)
# The shared DeckView is re-parented into this slot when the match view is shown. # Players + match info, then turn stats, then the deck lists; heights are draggable.
self.deck_slot = QWidget() self.split = QSplitter(Qt.Orientation.Vertical)
self.deck_slot_layout = QVBoxLayout(self.deck_slot) self.split.addWidget(top)
self.deck_slot_layout.setContentsMargins(0, 0, 0, 0) self.split.addWidget(turn_view)
self.split.addWidget(deck_view)
split = QSplitter(Qt.Orientation.Vertical) self.split.setChildrenCollapsible(False)
split.addWidget(top) self.split.setStretchFactor(0, 2)
split.addWidget(self.deck_slot) self.split.setStretchFactor(1, 3)
split.setChildrenCollapsible(False) self.split.setStretchFactor(2, 5)
split.setSizes([1, 1])
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(split) lay.addWidget(self.split)
def render(self, t: MatchTracker, now_ms: int) -> None: def render(self, t: MatchTracker, now_ms: int) -> None:
g = t.game g = t.game
@@ -246,8 +245,8 @@ class MatchView(QWidget):
on = self.toggles.on on = self.toggles.on
whose = "your turn" if g.active_player == g.my_seat else "their turn" whose = "your turn" if g.active_player == g.my_seat else "their turn"
self.turn_label.setText(str(g.turn or "–")) self.turn_label.setText(f"Turn {g.turn}" if g.turn else "Pre-game")
self.turn_caption.setText(whose if g.turn else "pre-game") self.turn_caption.setText(whose if g.turn else "")
phase = pretty_enum(g.phase) if g.phase else "" phase = pretty_enum(g.phase) if g.phase else ""
step = pretty_enum(g.step) if g.step else "" step = pretty_enum(g.step) if g.step else ""
self.phase.setText(" · ".join(x for x in (phase, step) if x)) self.phase.setText(" · ".join(x for x in (phase, step) if x))
+31 -3
View File
@@ -2,15 +2,17 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QPixmap, QResizeEvent from PySide6.QtGui import QMouseEvent, QPixmap, QResizeEvent
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton, QVBoxLayout, QWidget
from ..carddb import CardDB from ..carddb import CardDB
from ..images import ImageCache from ..images import ImageCache
class CardPreview(QWidget): class CardPreview(QWidget):
cleared = Signal()
def __init__(self, cards: CardDB, images: ImageCache, parent=None): def __init__(self, cards: CardDB, images: ImageCache, parent=None):
super().__init__(parent) super().__init__(parent)
self.cards = cards self.cards = cards
@@ -26,18 +28,44 @@ class CardPreview(QWidget):
self.caption.setWordWrap(True) self.caption.setWordWrap(True)
self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter) self.caption.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.close_btn = QToolButton()
self.close_btn.setText("✕")
self.close_btn.setToolTip("Stop previewing this card (Esc, or click the image)")
self.close_btn.clicked.connect(self.clear)
top = QHBoxLayout()
top.addStretch(1)
top.addWidget(self.close_btn)
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(4, 4, 4, 4) lay.setContentsMargins(4, 4, 4, 4)
lay.addLayout(top)
lay.addWidget(self.image, 1) lay.addWidget(self.image, 1)
lay.addWidget(self.caption) lay.addWidget(self.caption)
images.image_ready.connect(self._on_image_ready) images.image_ready.connect(self._on_image_ready)
self.clear()
def clear(self) -> None:
"""Back to the empty state."""
was_showing = self.grp_id is not None
self.grp_id = None
self.caption.setText("")
self._show_placeholder("Hover a card to preview it") self._show_placeholder("Hover a card to preview it")
self.close_btn.setVisible(False)
if was_showing:
self.cleared.emit()
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.grp_id is not None:
self.clear()
return
super().mousePressEvent(event)
def show_card(self, grp_id: int) -> None: def show_card(self, grp_id: int) -> None:
if grp_id == self.grp_id: if grp_id == self.grp_id:
return return
self.grp_id = grp_id self.grp_id = grp_id
self.close_btn.setVisible(True)
info = self.cards.get(grp_id) info = self.cards.get(grp_id)
details = [info.type_line] details = [info.type_line]
if info.power or info.toughness: if info.power or info.toughness:
+8 -1
View File
@@ -18,6 +18,7 @@ DANGER = "#e07a6b"
FONT_PT = 13 FONT_PT = 13
RADIUS = 10 RADIUS = 10
NAV_PADDING_X = 22
QSS = f""" QSS = f"""
* {{ outline: none; }} * {{ outline: none; }}
@@ -44,6 +45,12 @@ QFrame#card {{
}} }}
QLabel#cardTitle {{ color: {MINT_TEXT}; font-weight: bold; }} QLabel#cardTitle {{ color: {MINT_TEXT}; font-weight: bold; }}
QLabel#muted {{ color: {MUTED}; }} QLabel#muted {{ color: {MUTED}; }}
QLabel#statValue {{ font-size: {int(FONT_PT * 1.8)}pt; font-weight: bold; color: {TEXT}; }}
QPushButton#chip {{
background: #1b2f29; border: 1px solid {MINT}; color: {MINT_TEXT};
border-radius: 14px; padding: 4px 12px;
}}
QPushButton#chip:hover {{ background: #24443a; }}
QLabel#bigNumber {{ font-size: {FONT_PT * 3}pt; font-weight: 700; color: {TEXT}; }} QLabel#bigNumber {{ font-size: {FONT_PT * 3}pt; font-weight: 700; color: {TEXT}; }}
QFrame#pastBar {{ QFrame#pastBar {{
background: #1b2f29; border: 1px solid {MINT}; border-radius: {RADIUS}px; background: #1b2f29; border: 1px solid {MINT}; border-radius: {RADIUS}px;
@@ -62,7 +69,7 @@ QPushButton:disabled, QToolButton:disabled {{ color: {MUTED}; }}
QPushButton:checked, QToolButton:checked {{ QPushButton:checked, QToolButton:checked {{
background: {MINT}; color: {BG}; border-color: {MINT}; font-weight: bold; background: {MINT}; color: {BG}; border-color: {MINT}; font-weight: bold;
}} }}
QPushButton#navButton {{ padding: 8px 22px; border-radius: 16px; }} QPushButton#navButton {{ padding: 8px {NAV_PADDING_X}px; border-radius: 16px; }}
QToolButton::menu-indicator {{ image: none; width: 0; }} QToolButton::menu-indicator {{ image: none; width: 0; }}
QTreeWidget, QTableWidget, QListWidget {{ QTreeWidget, QTableWidget, QListWidget {{
+34 -22
View File
@@ -38,7 +38,7 @@ class StatsTable(QTableWidget):
self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setShowGrid(False) self.setShowGrid(False)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
self.verticalHeader().setDefaultSectionSize(34) self.verticalHeader().setDefaultSectionSize(28)
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;" self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;"
@@ -77,41 +77,53 @@ class TurnView(QWidget):
self.prev_btn = QPushButton("◀") self.prev_btn = QPushButton("◀")
self.next_btn = QPushButton("▶") self.next_btn = QPushButton("▶")
self.prev_btn.setToolTip("Previous turn")
self.next_btn.setToolTip("Next turn")
self.live_btn = QPushButton("Live") self.live_btn = QPushButton("Live")
self.live_btn.setCheckable(True) self.live_btn.setCheckable(True)
self.live_btn.setChecked(True) self.live_btn.setChecked(True)
self.live_btn.setToolTip("Follow the current turn")
self.title = QLabel() self.title = QLabel()
self.title.setObjectName("cardTitle") self.title.setObjectName("statValue")
self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.title.setMinimumWidth(260) self.whose = QLabel()
self.whose.setObjectName("muted")
self.whose.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.prev_btn.clicked.connect(lambda: self._step(-1)) self.prev_btn.clicked.connect(lambda: self._step(-1))
self.next_btn.clicked.connect(lambda: self._step(1)) self.next_btn.clicked.connect(lambda: self._step(1))
self.live_btn.toggled.connect(self._set_live) self.live_btn.toggled.connect(self._set_live)
nav = QHBoxLayout() # Turn picker: sits between the two tables.
nav.addStretch(1) arrows = QHBoxLayout()
nav.addWidget(self.prev_btn) arrows.addWidget(self.prev_btn)
nav.addWidget(self.title) arrows.addWidget(self.next_btn)
nav.addWidget(self.next_btn) picker = QWidget()
nav.addSpacing(12) picker_lay = QVBoxLayout(picker)
nav.addWidget(self.live_btn) picker_lay.setContentsMargins(0, 0, 0, 0)
nav.addStretch(1) picker_lay.addStretch(1)
picker_lay.addWidget(self.title)
picker_lay.addWidget(self.whose)
picker_lay.addSpacing(6)
picker_lay.addLayout(arrows)
picker_lay.addWidget(self.live_btn)
picker_lay.addStretch(1)
self.picker_frame = card_frame("Turn", picker)
self.picker_frame.setFixedWidth(170)
self.turn_table = StatsTable() self.turn_table = StatsTable()
self.game_table = StatsTable() self.game_table = StatsTable()
self.turn_frame = card_frame("Selected turn", self.turn_table) self.turn_frame = card_frame("Selected turn", self.turn_table)
tables = QHBoxLayout() self.game_frame = card_frame("Whole game", self.game_table)
tables.addWidget(self.turn_frame, 1)
tables.addWidget(card_frame("Whole game", self.game_table), 1)
self.empty = QLabel("Waiting for a game…") self.empty = QLabel("Turn stats appear once a game starts.")
self.empty.setObjectName("muted") self.empty.setObjectName("muted")
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
lay = QVBoxLayout(self) lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0) lay.setContentsMargins(0, 0, 0, 0)
lay.addLayout(nav) lay.addWidget(self.turn_frame, 1)
lay.addLayout(tables, 1) lay.addWidget(self.picker_frame)
lay.addWidget(self.game_frame, 1)
lay.addWidget(self.empty, 1) lay.addWidget(self.empty, 1)
def _turns(self, g: GameState) -> list[int]: def _turns(self, g: GameState) -> list[int]:
@@ -145,8 +157,7 @@ class TurnView(QWidget):
g = t.game g = t.game
has_game = g is not None has_game = g is not None
self.empty.setVisible(not has_game) self.empty.setVisible(not has_game)
for w in (self.turn_table, self.game_table, self.turn_frame, self.prev_btn, self.next_btn, for w in (self.turn_frame, self.picker_frame, self.game_frame):
self.live_btn, self.title):
w.setVisible(has_game) w.setVisible(has_game)
if g is None: if g is None:
return return
@@ -156,8 +167,9 @@ class TurnView(QWidget):
self.selected = None self.selected = None
shown = self.selected if self.selected is not None else g.turn shown = self.selected if self.selected is not None else g.turn
active = g.turns.active.get(shown) active = g.turns.active.get(shown)
whose = "yours" if active == g.my_seat else "theirs" if active else "" whose = "your" if active == g.my_seat else "their" if active else ""
self.title.setText(f"Turn {shown}" + (f" ({whose})" if whose else "") if shown else "Pre-game") self.title.setText(f"Turn {shown}" if shown else "Pre-game")
self.whose.setText(f"{whose} turn".capitalize() if whose else "")
self.prev_btn.setEnabled(bool(turns) and shown != turns[0]) self.prev_btn.setEnabled(bool(turns) and shown != turns[0])
self.next_btn.setEnabled(self.selected is not None) self.next_btn.setEnabled(self.selected is not None)
+22
View File
@@ -225,3 +225,25 @@ def test_commanders_are_not_emblems():
assert g.commander_status(OPP) == [(9000, "stack")] assert g.commander_status(OPP) == [(9000, "stack")]
assert g.commander_tax(OPP) == 2 assert g.commander_tax(OPP) == 2
assert g.commander_tax(ME) == 0 assert g.commander_tax(ME) == 0
def test_revealed_card_already_tracked_is_not_double_counted():
t = MatchTracker()
start_game(t)
# Their card is bounced back to hand (tracked), and a reveal shows the same card.
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
], objects=[card(210, 5005, BATTLEFIELD)])]))
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
zone(OPP_HAND, "Hand", [220], OPP), zone(BATTLEFIELD, "Battlefield", []),
], objects=[card(400, 5005, OPP_HAND, otype="RevealedCard")], deleted=[210])]))
assert t.game.opp_known_in("ZoneType_Hand") == {5005: 1}
def test_known_cards_never_exceed_zone_size():
t = MatchTracker()
start_game(t)
# Two reveals of different cards while their hand only has one card left.
t.handle(GreMessages([gsm(2, zones=[zone(OPP_HAND, "Hand", [201], OPP)], objects=[
card(400, 7007, OPP_HAND, otype="RevealedCard"), card(401, 7008, OPP_HAND, otype="RevealedCard")])]))
assert sum(t.game.opp_known_in("ZoneType_Hand").values()) == 1
+33
View File
@@ -65,3 +65,36 @@ def test_recorder_ignores_unfinished_match(tmp_path):
t.handle(ev) t.handle(ev)
rec.observe(ev, t) rec.observe(ev, t)
assert store.games() == [] assert store.games() == []
def test_old_database_is_upgraded_and_backfilled(tmp_path):
import sqlite3
path = tmp_path / "old.db"
store = HistoryStore(path)
import_log(FIXTURE, store, None)
# Simulate a database from before commanders were stored.
conn = sqlite3.connect(path)
conn.execute("UPDATE games SET my_commander = NULL, opp_commander = NULL")
conn.commit()
conn.close()
store = HistoryStore(path)
assert all(g.my_commander is None for g in store.games())
assert store.backfill_commanders(None) == 2
assert all(g.my_commander == "" and g.opp_commander == "" for g in store.games()) # not a Brawl match
assert store.backfill_commanders(None) == 0
def test_schema_without_commander_columns_gets_them(tmp_path):
import sqlite3
path = tmp_path / "v1.db"
conn = sqlite3.connect(path)
conn.executescript("""CREATE TABLE games (match_id TEXT NOT NULL, game_number INTEGER NOT NULL,
started_ms INTEGER, ended_ms INTEGER, event_id TEXT, opponent TEXT, deck_name TEXT, on_play TEXT,
result TEXT, my_colors TEXT, opp_colors TEXT, turns INTEGER, best_of INTEGER,
PRIMARY KEY (match_id, game_number));
INSERT INTO games VALUES ('m1', 1, 1, 2, 'Ladder', 'Opp', 'Deck', 'me', 'Won', 'U', 'R', 5, 1);""")
conn.commit()
conn.close()
games = HistoryStore(path).games()
assert len(games) == 1 and games[0].deck_name == "Deck" and games[0].my_commander is None
+58
View File
@@ -0,0 +1,58 @@
from mtga_assistant.history import GameSummary
from mtga_assistant.history_stats import WEEK_MS, Filter, apply_filters, compute_stats
NOW = 1_800_000_000_000
DAY = 24 * 3600 * 1000
def game(n: int, result: str, *, deck="Deck A", on_play="me", days_ago=0, turns=8, length_s=300,
event="Ladder", best_of=1, game_number=1) -> GameSummary:
start = NOW - days_ago * DAY - n * 1000
return GameSummary(match_id=f"m{n}", game_number=game_number, started_ms=start, ended_ms=start + length_s * 1000,
event_id=event, opponent=f"opp{n}", deck_name=deck, on_play=on_play, result=result,
my_colors="UB", opp_colors="R", turns=turns, best_of=best_of)
def test_filters_only_and_hide_combine():
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="B"), game(3, "Won", deck="A", on_play="opp")]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A")])] == ["m1", "m3"]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A"), Filter("on_play", "opp")])] == ["m3"]
assert [g.match_id for g in apply_filters(games, [Filter("deck", "A", exclude=True)])] == ["m2"]
def test_filter_labels_are_readable():
assert Filter("on_play", "opp").label == "On the play = you on the draw"
assert Filter("event", "Play_Brawl_Historic", exclude=True).label == "Event ≠ Play Brawl Historic"
assert Filter("game", "Bo1").label == "Game = Bo1"
def test_stats_windows_and_averages():
# 12 recent games (newest first: n=0 is newest): W W L W W L W W L W | L L
results = ["Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Won", "Lost", "Won", "Lost", "Lost"]
games = [game(n, r, days_ago=0 if n < 6 else 10, turns=6 + n % 3, length_s=240 + 60 * (n % 2),
on_play="me" if n % 2 == 0 else "opp")
for n, r in enumerate(results)]
games.append(game(99, "", days_ago=1)) # no result: counted as a game, not in win rates
s = compute_stats(games, NOW)
assert s.games == 13
assert (s.overall.wins, s.overall.losses) == (7, 5)
assert (s.last_10.wins, s.last_10.losses) == (7, 3)
assert s.last_10.rate == 0.7
assert s.last_100.games == 12
assert s.week_games == 7 and (s.week.wins, s.week.losses) == (4, 2)
assert (s.on_play.wins, s.on_play.losses) == (3, 3)
assert (s.on_draw.wins, s.on_draw.losses) == (4, 2)
assert s.avg_turns is not None and 6 <= s.avg_turns <= 8
assert s.avg_length_s is not None and 240 <= s.avg_length_s <= 300
def test_stats_follow_filters():
games = [game(1, "Won", deck="A"), game(2, "Lost", deck="A"), game(3, "Lost", deck="B"), game(4, "Lost", deck="B")]
assert compute_stats(apply_filters(games, [Filter("deck", "A")]), NOW).overall.rate == 0.5
assert compute_stats(apply_filters(games, [Filter("deck", "B")]), NOW).overall.rate == 0.0
def test_empty_history():
s = compute_stats([], NOW)
assert s.games == 0 and s.overall.rate is None and s.avg_length_s is None and s.week_games == 0
assert WEEK_MS == 7 * DAY