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:
1 parent
734336168c
commit
7dc2ce0da8
21 files changed
+2736
No files matched your search
@@ -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")])
|
||||
Reference in new issue
Block a user