Add match, turn and history views with a dark theme
- Top bar switching between Deck, Match, Turn and History views - Match view: life, player counters, commanders, emblems, zone counts, turn timer and timeouts, land drops used/allowed, play/draw and Bo3 draw counts, with the deck view below - Turn view: per-player and total stats per turn with history, plus whole-game totals and land drops - History: every finished game (colours, winner, who was on the play); click to reopen it in the other views; imports Player-prev.log - Customize menu to show/hide items and an Arrange dialog to reorder deck sections and turn stats, remembered across sessions - Static dark theme with mint accent, rounded corners, larger text - Commanders tracked separately from emblems, with location and tax - Quieter, non-sticky warning for cards lost to MTGA log gaps - Correct Scryfall images for tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dc2ce0da8
commit
5b3c8c8e63
22 files changed
+2599
-193
No files matched your search
@@ -1,130 +1,264 @@
|
||||
"""Main window: my deck | opponent | card preview."""
|
||||
"""Main window: a top bar switching between the Deck, Match and Turn views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
|
||||
from PySide6.QtGui import QAction, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..carddb import CardDB
|
||||
from ..gamestate import GameState, MatchTracker
|
||||
from ..gamestate import MatchTracker
|
||||
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
|
||||
from ..images import ImageCache
|
||||
from ..logtail import LogFollower
|
||||
from .card_list import CardList, Row, Section
|
||||
from .preview import CardPreview
|
||||
from . import theme
|
||||
from .arrange import ArrangeDialog
|
||||
from .deck_view import MY_SECTIONS, OPP_SECTIONS, DeckView
|
||||
from .history_view import HistoryView, fmt_when
|
||||
from .match_view import MATCH_TOGGLES, MatchView
|
||||
from .toggles import StayOpenMenu, Toggles
|
||||
from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
|
||||
|
||||
REFRESH_MS = 150
|
||||
DECK_TOGGLES = [("preview", "Card preview pane")]
|
||||
VIEWS = ["Deck", "Match", "Turn", "History"]
|
||||
HISTORY = 3
|
||||
|
||||
|
||||
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 HistoryImport(QThread):
|
||||
"""Records completed matches from an older log (Player-prev.log) in the background."""
|
||||
|
||||
done = Signal(int)
|
||||
|
||||
def __init__(self, path: Path, store: HistoryStore, cards: CardDB):
|
||||
super().__init__()
|
||||
self.path, self.store, self.cards = path, store, cards
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
st = self.path.stat()
|
||||
except OSError:
|
||||
return
|
||||
stamp = f"{st.st_size}:{st.st_mtime_ns}"
|
||||
key = f"imported:{self.path}"
|
||||
if self.store.meta(key) == stamp:
|
||||
return
|
||||
# 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.done.emit(saved)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
|
||||
def __init__(self, cards: CardDB, images: ImageCache, history: HistoryStore, log_path: Path | None,
|
||||
replay_rate: float | None = None):
|
||||
super().__init__()
|
||||
self.setWindowTitle("MTGA Assistant")
|
||||
self.cards = cards
|
||||
self.images = images
|
||||
self.history = history
|
||||
self.recorder = HistoryRecorder(history)
|
||||
self.tracker = MatchTracker(card_lookup=cards)
|
||||
# A past game opened from the History view: (tracker, summary text). None = live.
|
||||
self.past: tuple[MatchTracker, str] | None = None
|
||||
self._importers: list[HistoryImport] = []
|
||||
self.follower: LogFollower | None = None
|
||||
self._rendered_version = -1
|
||||
self.replaying = False
|
||||
self._rendered: dict[int, int] = {} # view index -> tracker version last rendered
|
||||
self._settings = QSettings("mtga-assistant", "mtga-assistant")
|
||||
|
||||
# Header
|
||||
self.toggles = [
|
||||
Toggles("deck", DECK_TOGGLES, parent=self),
|
||||
Toggles("match", MATCH_TOGGLES, parent=self),
|
||||
Toggles("turn", TURN_TOGGLES, TURN_DEFAULTS_OFF, parent=self),
|
||||
]
|
||||
self.my_sections = Toggles("deck_mine", MY_SECTIONS, title="My deck", parent=self)
|
||||
self.opp_sections = Toggles("deck_opp", OPP_SECTIONS, title="Opponent", parent=self)
|
||||
self.toggles[2].title = "Turn stats"
|
||||
for tg in (*self.toggles, self.my_sections, self.opp_sections):
|
||||
tg.changed.connect(self._force_refresh)
|
||||
|
||||
# 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_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.history_view = HistoryView()
|
||||
self.history_view.open_game.connect(self.open_past_game)
|
||||
self.stack = QStackedWidget()
|
||||
for page in (self.deck_page, self.match_view, self.turn_view, self.history_view):
|
||||
self.stack.addWidget(page)
|
||||
|
||||
# Top bar
|
||||
self.nav = QButtonGroup(self)
|
||||
self.nav.setExclusive(True)
|
||||
bar = QHBoxLayout()
|
||||
bar.setSpacing(8)
|
||||
for i, name in enumerate(VIEWS):
|
||||
btn = QPushButton(name)
|
||||
btn.setObjectName("navButton")
|
||||
btn.setCheckable(True)
|
||||
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
|
||||
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
|
||||
self.nav.addButton(btn, i)
|
||||
bar.addWidget(btn)
|
||||
self.nav.idClicked.connect(self.show_view)
|
||||
bar.addSpacing(16)
|
||||
self.header = QLabel("Waiting for a match…")
|
||||
self.header.setTextFormat(Qt.TextFormat.RichText)
|
||||
bar.addWidget(self.header, 1)
|
||||
self.customize = QToolButton()
|
||||
self.customize.setText("⚙ Customize")
|
||||
self.customize.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
self.customize_menu = StayOpenMenu(self)
|
||||
self.customize_menu.aboutToShow.connect(self._fill_customize)
|
||||
self.customize.setMenu(self.customize_menu)
|
||||
bar.addWidget(self.customize)
|
||||
bar.addWidget(self._app_menu_button())
|
||||
|
||||
self.warning = QLabel()
|
||||
self.warning.setStyleSheet("color: #d08770;")
|
||||
self.warning.setObjectName("banner")
|
||||
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)
|
||||
# Shown while looking at a past game.
|
||||
self.past_bar = QFrame()
|
||||
self.past_bar.setObjectName("pastBar")
|
||||
past_lay = QHBoxLayout(self.past_bar)
|
||||
past_lay.setContentsMargins(12, 6, 8, 6)
|
||||
self.past_label = QLabel()
|
||||
self.past_label.setTextFormat(Qt.TextFormat.RichText)
|
||||
back = QPushButton("Back to live")
|
||||
back.clicked.connect(self.close_past_game)
|
||||
past_lay.addWidget(self.past_label, 1)
|
||||
past_lay.addWidget(back)
|
||||
self.past_bar.hide()
|
||||
|
||||
central = QWidget()
|
||||
central.setObjectName("page")
|
||||
lay = QVBoxLayout(central)
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(self.header, 1)
|
||||
lay.addLayout(top)
|
||||
lay.setContentsMargins(12, 10, 12, 6)
|
||||
lay.setSpacing(10)
|
||||
lay.addLayout(bar)
|
||||
lay.addWidget(self.warning)
|
||||
lay.addWidget(split, 1)
|
||||
lay.addWidget(self.past_bar)
|
||||
lay.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Status bar
|
||||
self.status_log = QLabel()
|
||||
self.status_cache = QLabel()
|
||||
self.status_gap = QLabel()
|
||||
self.status_gap.setStyleSheet(f"color: {theme.DANGER};")
|
||||
self.status_gap.hide()
|
||||
self.statusBar().addWidget(self.status_log, 1)
|
||||
self.statusBar().addPermanentWidget(self.status_gap)
|
||||
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.clock_timer = QTimer(self)
|
||||
self.clock_timer.setInterval(1000)
|
||||
self.clock_timer.timeout.connect(self._tick)
|
||||
self.clock_timer.start()
|
||||
|
||||
self.resize(1280, 800)
|
||||
self.resize(1400, 880)
|
||||
self.history_view.set_games(history.games())
|
||||
self.show_view(int(self._settings.value("view", 0, type=int)))
|
||||
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…")
|
||||
self.status_log.setText("Player.log not found: use ☰ → Open log…")
|
||||
|
||||
# -- menu ----------------------------------------------------------------
|
||||
# -- top bar -------------------------------------------------------------
|
||||
|
||||
def _build_menu(self) -> None:
|
||||
file_menu = self.menuBar().addMenu("&File")
|
||||
open_act = QAction("&Open log…", self)
|
||||
def _app_menu_button(self) -> QToolButton:
|
||||
btn = QToolButton()
|
||||
btn.setText("☰")
|
||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
menu = QMenu(self)
|
||||
open_act = menu.addAction("Open log…")
|
||||
open_act.triggered.connect(lambda: self._choose_log(None))
|
||||
replay_act = QAction("&Replay log…", self)
|
||||
replay_act = menu.addAction("Replay log…")
|
||||
replay_act.triggered.connect(lambda: self._choose_log(40.0))
|
||||
quit_act = QAction("&Quit", self)
|
||||
menu.addSeparator()
|
||||
clear_act = menu.addAction("Clear image cache")
|
||||
clear_act.triggered.connect(self._clear_cache)
|
||||
menu.addSeparator()
|
||||
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)
|
||||
self.addAction(quit_act) # keep the shortcut active while the menu is closed
|
||||
menu.addAction(quit_act)
|
||||
btn.setMenu(menu)
|
||||
return btn
|
||||
|
||||
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 _arrangeable(self) -> list[Toggles]:
|
||||
"""Toggle groups whose order can be arranged in the current view (deck view in match view too)."""
|
||||
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:
|
||||
menu = self.customize_menu
|
||||
menu.clear()
|
||||
index = self.stack.currentIndex()
|
||||
if index == 2:
|
||||
self.toggles[2].fill_menu(menu)
|
||||
else:
|
||||
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)
|
||||
menu.addMenu(sub)
|
||||
menu.addSeparator()
|
||||
arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…")
|
||||
arrange.triggered.connect(self._open_arrange)
|
||||
|
||||
def _open_arrange(self) -> None:
|
||||
index = self.stack.currentIndex()
|
||||
title = "Arrange turn stats" if index == 2 else "Arrange deck sections"
|
||||
ArrangeDialog(self._arrangeable(), title, self).exec()
|
||||
|
||||
def show_view(self, index: int) -> None:
|
||||
index = max(0, min(len(VIEWS) - 1, index))
|
||||
self.customize.setEnabled(index != HISTORY)
|
||||
# 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.nav.button(index).setChecked(True)
|
||||
self._settings.setValue("view", index)
|
||||
self._force_refresh()
|
||||
|
||||
# -- menu actions ----------------------------------------------------------
|
||||
|
||||
def _choose_log(self, replay_rate: float | None) -> None:
|
||||
start = str(self.follower.path.parent) if self.follower else str(Path.home())
|
||||
@@ -138,7 +272,7 @@ class MainWindow(QMainWindow):
|
||||
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")
|
||||
self.status_cache.setText(f"Image cache {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
|
||||
|
||||
# -- log -----------------------------------------------------------------
|
||||
|
||||
@@ -147,6 +281,7 @@ class MainWindow(QMainWindow):
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
self._reset_tracker()
|
||||
self.replaying = replay_rate is not None
|
||||
self.follower = LogFollower(path, replay_rate)
|
||||
self.follower.events.connect(self._on_events)
|
||||
self.follower.status.connect(self.status_log.setText)
|
||||
@@ -155,13 +290,56 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _reset_tracker(self) -> None:
|
||||
self.tracker = MatchTracker(card_lookup=self.cards)
|
||||
self._rendered_version = -1
|
||||
self.recorder.reset()
|
||||
self._force_refresh()
|
||||
|
||||
def _on_events(self, events: list) -> None:
|
||||
saved = False
|
||||
for ev in events:
|
||||
self.tracker.handle(ev)
|
||||
saved |= self.recorder.observe(ev, self.tracker)
|
||||
if saved:
|
||||
self.history_view.set_games(self.history.games())
|
||||
|
||||
# -- history ---------------------------------------------------------------
|
||||
|
||||
def import_history(self, path: Path) -> None:
|
||||
worker = HistoryImport(path, self.history, self.cards)
|
||||
worker.done.connect(lambda n: n and self.history_view.set_games(self.history.games()))
|
||||
worker.finished.connect(lambda: self._importers.remove(worker))
|
||||
self._importers.append(worker)
|
||||
worker.start()
|
||||
|
||||
def open_past_game(self, match_id: str, game_number: int) -> None:
|
||||
summary = next((g for g in self.history.games()
|
||||
if g.match_id == match_id and g.game_number == game_number), None)
|
||||
events = self.history.events(match_id)
|
||||
if summary is None or not events:
|
||||
return
|
||||
tracker = replay_game(events, game_number, self.cards)
|
||||
when = fmt_when(summary.started_ms)
|
||||
result = {"Won": "won", "Lost": "lost"}.get(summary.result, "")
|
||||
text = (f"Viewing a past game: <b>vs {summary.opponent or '?'}</b> · {when}"
|
||||
+ (f" · game {game_number} of 3" if summary.best_of == 3 else "")
|
||||
+ (f" · you {result}" if result else ""))
|
||||
self.past = (tracker, text)
|
||||
self.past_label.setText(text)
|
||||
self.past_bar.show()
|
||||
self.turn_view.selected = None
|
||||
self.show_view(0)
|
||||
|
||||
def close_past_game(self) -> None:
|
||||
self.past = None
|
||||
self.past_bar.hide()
|
||||
self._force_refresh()
|
||||
|
||||
@property
|
||||
def shown_tracker(self) -> MatchTracker:
|
||||
return self.past[0] if self.past else self.tracker
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for worker in list(self._importers):
|
||||
worker.wait(5000)
|
||||
if self.follower:
|
||||
self.follower.stop()
|
||||
self.follower.wait(2000)
|
||||
@@ -174,117 +352,69 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# -- 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 now_ms(self) -> int:
|
||||
"""Wall clock when following live; the newest log time when replaying or viewing the past."""
|
||||
t = self.shown_tracker
|
||||
if (self.replaying or self.past) and t.last_ms:
|
||||
return t.last_ms
|
||||
return int(time.time() * 1000)
|
||||
|
||||
def _force_refresh(self) -> None:
|
||||
self._rendered.clear()
|
||||
self.refresh()
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self.stack.currentIndex() == 1:
|
||||
self.match_view.tick(self.shown_tracker, self.now_ms())
|
||||
|
||||
def refresh(self) -> None:
|
||||
t = self.tracker
|
||||
if t.version == self._rendered_version:
|
||||
t = self.shown_tracker
|
||||
index = self.stack.currentIndex()
|
||||
key = (id(t), t.version)
|
||||
if self._rendered.get(index) == key:
|
||||
return
|
||||
self._rendered_version = t.version
|
||||
self._rendered[index] = key
|
||||
self._render_header(t)
|
||||
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
|
||||
if index in (0, 1):
|
||||
self.deck_view.render(t)
|
||||
if index == 1:
|
||||
self.match_view.render(t, self.now_ms())
|
||||
elif index == 2:
|
||||
self.turn_view.render(t)
|
||||
|
||||
def _render_header(self, t: MatchTracker) -> None:
|
||||
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()
|
||||
self.header.setText("<span style='color:#8b949c'>Waiting for a match…</span>")
|
||||
else:
|
||||
self.warning.setVisible(not self.cards.available)
|
||||
parts = [f"<b>vs {t.opponent_name or 'opponent'}</b>"]
|
||||
if t.event_id:
|
||||
parts.append(t.event_id.replace("_", " "))
|
||||
parts.append(f"Game {g.game_number}")
|
||||
if g.turn:
|
||||
parts.append(f"Turn {g.turn}")
|
||||
if not t.in_match and t.last_result:
|
||||
parts.append(f"<b>Match {t.last_result.lower()}</b>")
|
||||
self.header.setText(" · ".join(parts))
|
||||
|
||||
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")])
|
||||
messages = []
|
||||
if not self.cards.available:
|
||||
messages.append("MTGA card database not found: card names are unavailable. "
|
||||
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
|
||||
# MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the
|
||||
# gap in; only flag it (quietly) while some of my cards are still not accounted for.
|
||||
unaccounted = 0
|
||||
if g is not None and g.zones:
|
||||
lib_size = g.zone_size("ZoneType_Library", g.my_seat)
|
||||
unaccounted = sum(t.my_library_remaining().values()) - lib_size
|
||||
if unaccounted > 0:
|
||||
self.status_gap.setText(f"⚠ {unaccounted} of your cards not tracked")
|
||||
self.status_gap.setToolTip(
|
||||
"MTGA left part of this game out of its log (an oversized message), so "
|
||||
f"{unaccounted} of your cards that left the library weren't seen. Library odds may be "
|
||||
"slightly off until those cards show up again."
|
||||
)
|
||||
self.status_gap.setVisible(unaccounted > 0)
|
||||
self.warning.setText("\n".join(messages))
|
||||
self.warning.setVisible(bool(messages))
|
||||
Reference in new issue
Block a user