Add MTGA Assistant: native Linux deck and game tracker

PySide6 app that follows MTG Arena's Player.log under Steam/Proton and shows
my remaining library with draw odds, my zones, and everything revealed about
the opponent (board, graveyard, exile, known hand/library, seen this match).

- Auto-detects the Steam library, Proton prefix log and MTGA install
- Card names/costs/types from MTGA's local SQLite card database
- Tracks card identity across zone changes, shuffles and Bo3 games
- Scryfall card images with a lazy, size-capped LRU disk cache
- Live log following with restart detection, plus --replay mode
- Flags log gaps where MTGA omits oversized game-state messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 09:56:12 -04:00
1 parent 734336168c
commit 7dc2ce0da8
21 files changed
+2736

No files matched your search

+290
View File
@@ -0,0 +1,290 @@
"""Main window: my deck | opponent | card preview."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QSplitter,
QVBoxLayout,
QWidget,
)
from ..carddb import CardDB
from ..gamestate import GameState, MatchTracker
from ..images import ImageCache
from ..logtail import LogFollower
from .card_list import CardList, Row, Section
from .preview import CardPreview
REFRESH_MS = 150
def _panel(title: str, body: QWidget) -> QWidget:
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(4, 4, 4, 4)
label = QLabel(f"<b>{title}</b>")
lay.addWidget(label)
lay.addWidget(body, 1)
return w
class MainWindow(QMainWindow):
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
replay_rate: float | None = None):
super().__init__()
self.setWindowTitle("MTGA Assistant")
self.cards = cards
self.images = images
self.tracker = MatchTracker(card_lookup=cards)
self.follower: LogFollower | None = None
self._rendered_version = -1
# Header
self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText)
self.warning = QLabel()
self.warning.setStyleSheet("color: #d08770;")
self.warning.setWordWrap(True)
self.warning.hide()
# Panels
self.mine = CardList()
self.opp = CardList()
self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(_panel("My deck", self.mine))
split.addWidget(_panel("Opponent", self.opp))
split.addWidget(self.preview)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3)
split.setStretchFactor(2, 2)
central = QWidget()
lay = QVBoxLayout(central)
top = QHBoxLayout()
top.addWidget(self.header, 1)
lay.addLayout(top)
lay.addWidget(self.warning)
lay.addWidget(split, 1)
self.setCentralWidget(central)
# Status bar
self.status_log = QLabel()
self.status_cache = QLabel()
self.statusBar().addWidget(self.status_log, 1)
self.statusBar().addPermanentWidget(self.status_cache)
images.size_changed.connect(self._on_cache_size)
self._on_cache_size(images.disk_usage())
if not cards.available:
self.warning.setText("MTGA card database not found: card names are unavailable. "
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
self.warning.show()
self._build_menu()
self.refresh_timer = QTimer(self)
self.refresh_timer.setInterval(REFRESH_MS)
self.refresh_timer.timeout.connect(self.refresh)
self.refresh_timer.start()
self.resize(1280, 800)
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
self.status_log.setText("Player.log not found: use File → Open log…")
# -- menu ----------------------------------------------------------------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&File")
open_act = QAction("&Open log…", self)
open_act.triggered.connect(lambda: self._choose_log(None))
replay_act = QAction("&Replay log…", self)
replay_act.triggered.connect(lambda: self._choose_log(40.0))
quit_act = QAction("&Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
file_menu.addActions([open_act, replay_act])
file_menu.addSeparator()
file_menu.addAction(quit_act)
cache_menu = self.menuBar().addMenu("&Cache")
clear_act = QAction("&Clear image cache", self)
clear_act.triggered.connect(self._clear_cache)
cache_menu.addAction(clear_act)
def _choose_log(self, replay_rate: float | None) -> None:
start = str(self.follower.path.parent) if self.follower else str(Path.home())
path, _ = QFileDialog.getOpenFileName(self, "Choose an MTGA log", start, "Logs (*.log);;All files (*)")
if path:
self.start_log(Path(path), replay_rate)
def _clear_cache(self) -> None:
if QMessageBox.question(self, "Clear image cache",
f"Delete all cached card images in {self.images.dir}?") == QMessageBox.StandardButton.Yes:
self.images.clear()
def _on_cache_size(self, size: int) -> None:
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
# -- log -----------------------------------------------------------------
def start_log(self, path: Path, replay_rate: float | None = None) -> None:
if self.follower:
self.follower.stop()
self.follower.wait(2000)
self._reset_tracker()
self.follower = LogFollower(path, replay_rate)
self.follower.events.connect(self._on_events)
self.follower.status.connect(self.status_log.setText)
self.follower.reset.connect(self._reset_tracker)
self.follower.start()
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
self._rendered_version = -1
def _on_events(self, events: list) -> None:
for ev in events:
self.tracker.handle(ev)
def shutdown(self) -> None:
if self.follower:
self.follower.stop()
self.follower.wait(2000)
self.follower = None
self.images.shutdown()
def closeEvent(self, event) -> None:
self.shutdown()
super().closeEvent(event)
# -- rendering -------------------------------------------------------------
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
rows = []
for grp, n in counts.items():
if n <= 0:
continue
info = self.cards.get(grp)
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
return rows
def refresh(self) -> None:
t = self.tracker
if t.version == self._rendered_version:
return
self._rendered_version = t.version
g = t.game
self._render_header(t, g)
if g is None:
self._render_idle(t)
return
self._render_mine(t, g)
self._render_opp(t, g)
def _render_header(self, t: MatchTracker, g: GameState | None) -> None:
if g is None:
self.header.setText("Waiting for a match…")
self.warning.setVisible(not self.cards.available)
return
opp = t.opponent_name or "opponent"
parts = [f"<b>vs {opp}</b>"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
whose = "your" if g.active_player == g.my_seat else "their"
parts.append(f"Turn {g.turn} ({whose})")
if g.life:
parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}")
if t.game_results:
w = t.game_results.count("Won")
parts.append(f"Games {w}-{len(t.game_results) - w}")
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0
if g.desynced or unaccounted:
self.warning.setText(
"Part of the game log was skipped by MTGA (oversized message), so some zones may be "
"out of date until they next change."
+ (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "")
)
self.warning.show()
else:
self.warning.setVisible(not self.cards.available)
def _render_idle(self, t: MatchTracker) -> None:
sections = []
if d := t.submitted_deck:
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
self._rows(d.main)))
if d.sideboard:
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
self._rows(d.sideboard), collapsed_by_default=True))
self.mine.set_sections(sections)
self.opp.set_sections([])
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
me = g.my_seat
lib_size = g.zone_size("ZoneType_Library", me)
remaining = t.my_library_remaining()
denom = lib_size or sum(remaining.values()) or 1
def pct(_grp: int, n: int) -> str:
return f"{100 * n / denom:.0f}%"
# Every card in the deck; ones with no copies left stay listed, greyed out.
lib_rows = [
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
for grp in g.deck
for n in [remaining.get(grp, 0)]
]
sections = [
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("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True),
]
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
opp = g.opp_seat
hand_size = g.zone_size("ZoneType_Hand", opp)
lib_size = g.zone_size("ZoneType_Library", opp)
known_hand = g.opp_known_in("ZoneType_Hand")
known_lib = g.opp_known_in("ZoneType_Library")
seen = t.opp_seen_match
sections = [
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
[r for r in self._rows(seen) if not r.info.is_token]),
]
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])