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:
1 parent
5b3c8c8e63
commit
b3325e8ecc
15 files changed
+754
-177
No files matched your search
@@ -3,31 +3,30 @@
|
||||
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:
|
||||
|
||||
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**
|
||||
- *My deck*: remaining library with draw odds, hand, battlefield, graveyard, exile, sideboard.
|
||||
Between matches, it shows the last deck you submitted.
|
||||
- *Opponent*: battlefield, stack, graveyard, exile, cards *known* to be in their hand or library
|
||||
(cards you saw that went back into a hidden zone, or were revealed), and every card seen this match.
|
||||
- *Card preview*: hover a card to see its image from [Scryfall](https://scryfall.com).
|
||||
- **Match**: life totals, poison/energy and other player counters, emblems, hand/library/graveyard
|
||||
counts, turn timer and timeouts used, turn/phase, Bo3 score and game time. The deck view fills
|
||||
the bottom half.
|
||||
- **Turn**: what happened this turn, for you, your opponent and in total: cards drawn, spells cast,
|
||||
lands played, creatures died, life gained/lost, damage, discards, mills, exiles, counters and
|
||||
tokens, plus land drops used/allowed. Use ◀ ▶ to browse earlier turns; the "Whole game" table
|
||||
shows game totals.
|
||||
- **History**: every finished game with date, event, opponent, both decks' colours, who was on
|
||||
the play, the result, turns and length. Click a game to open it in the Deck/Match/Turn views
|
||||
("Back to live" returns). Games from the previous MTGA session (`Player-prev.log`) are imported
|
||||
on startup.
|
||||
- **Match**, from top to bottom (drag the dividers to resize; sizes are remembered):
|
||||
- *You · Match · Opponent*: life totals, poison/energy and other player counters, commanders,
|
||||
emblems, hand/library/graveyard counts, turn timer and timeouts, land drops used/allowed, who
|
||||
was on the play, plus the match panel (turn, phase, game number, Bo3 score and draw count,
|
||||
game time).
|
||||
- *Selected turn · Turn · Whole game*: what happened in a turn for you, your opponent and in
|
||||
total (cards drawn, spells cast, lands played, creatures died, life gained/lost, damage,
|
||||
discards, mills, exiles, counters, tokens, land drops). The middle panel picks the turn
|
||||
(◀ ▶, or *Live* to follow the current one).
|
||||
- *My deck · Opponent · Preview*: remaining library with draw odds, your zones; the opponent's
|
||||
board, graveyard, exile, cards known to be in their hand or library, and every card seen this
|
||||
match; hover a card for its [Scryfall](https://scryfall.com) image.
|
||||
- **History**: stats (win rate overall, last 10/100 games, last 7 days, on the play/draw, average
|
||||
length and turns) and a sortable list of every finished game: date, event, deck, commanders,
|
||||
opponent, both decks' colours, who was on the play, result, turns and length. Click a header to
|
||||
sort, right-click a cell to show only (or hide) games with that value; the stats follow the
|
||||
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 items in the current view. *Customize → Arrange…* lets you
|
||||
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.
|
||||
*⚙ 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
|
||||
and the turn stats. Your choices are remembered.
|
||||
|
||||
Card names, mana costs and types come from MTGA's own local card database, so they work offline.
|
||||
|
||||
|
||||
@@ -415,22 +415,31 @@ class GameState:
|
||||
return len(z.ids) if z else 0
|
||||
|
||||
def opp_known_in(self, ztype: str) -> Counter[int]:
|
||||
"""Opponent cards we know are in a hidden zone (by tracking identity)."""
|
||||
out: Counter[int] = Counter()
|
||||
"""Opponent cards we know are in a hidden zone (by tracking identity, or revealed)."""
|
||||
z = self.zone_of_type(ztype, self.opp_seat)
|
||||
if z:
|
||||
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 z is None:
|
||||
return Counter()
|
||||
tracked: Counter[int] = Counter(self.known[iid] for iid in z.ids if iid in self.known)
|
||||
if ztype == "ZoneType_Library":
|
||||
out += self.opp_library_unplaced
|
||||
return out
|
||||
tracked += self.opp_library_unplaced
|
||||
# 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]:
|
||||
merged: dict[int, set[int]] = {}
|
||||
|
||||
@@ -44,6 +44,8 @@ CREATE TABLE IF NOT EXISTS games (
|
||||
opp_colors TEXT,
|
||||
turns 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)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS matches (match_id TEXT PRIMARY KEY, events BLOB NOT NULL);
|
||||
@@ -66,9 +68,15 @@ class GameSummary:
|
||||
opp_colors: str
|
||||
turns: int
|
||||
best_of: int
|
||||
my_commander: str | None = ""
|
||||
opp_commander: str | None = ""
|
||||
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 ------------------------------------------------------
|
||||
|
||||
|
||||
@@ -118,6 +126,11 @@ class HistoryStore:
|
||||
self._lock = threading.Lock()
|
||||
self._conn = sqlite3.connect(path, check_same_thread=False)
|
||||
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()
|
||||
|
||||
def has_match(self, match_id: str) -> bool:
|
||||
@@ -128,9 +141,10 @@ class HistoryStore:
|
||||
blob = gzip.compress(json.dumps(events, separators=(",", ":")).encode())
|
||||
with self._lock:
|
||||
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.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._prune()
|
||||
@@ -155,10 +169,34 @@ class HistoryStore:
|
||||
def games(self) -> list[GameSummary]:
|
||||
with self._lock:
|
||||
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"""
|
||||
).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]:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
cards = t.card_lookup
|
||||
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,
|
||||
result=result, my_colors=colors_of(cards, my_cards), opp_colors=colors_of(cards, opp_cards),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -40,6 +40,13 @@ class Row:
|
||||
count: int = 1
|
||||
note: str = ""
|
||||
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
|
||||
@@ -144,7 +151,7 @@ class CardList(QTreeWidget):
|
||||
|
||||
def set_sections(self, sections: list[Section]) -> None:
|
||||
# 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:
|
||||
return
|
||||
self._last_render = signature
|
||||
@@ -169,7 +176,7 @@ class CardList(QTreeWidget):
|
||||
self.addTopLevelItem(top)
|
||||
top.setFirstColumnSpanned(True)
|
||||
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(COL_MANA, MANA_ROLE, list(row.info.mana))
|
||||
it.setToolTip(COL_NAME, f"{row.info.name}\n{row.info.type_line}")
|
||||
|
||||
@@ -71,20 +71,26 @@ class DeckView(QWidget):
|
||||
self.preview = CardPreview(cards, images)
|
||||
self.mine.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.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("Opponent", self.opp))
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 3)
|
||||
split.setStretchFactor(2, 2)
|
||||
split.setStretchFactor(1, 2)
|
||||
split.setStretchFactor(2, 3)
|
||||
split.setChildrenCollapsible(False)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
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]:
|
||||
return [
|
||||
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.
|
||||
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 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) + [
|
||||
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("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=me))),
|
||||
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
|
||||
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", zone_rows(g.cards_in("ZoneType_Hand", owner=me))),
|
||||
Section("stack", "Stack", zone_rows(g.cards_in("ZoneType_Stack", owner=me))),
|
||||
Section("battlefield", "Battlefield", zone_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))),
|
||||
zone_rows(g.cards_in("ZoneType_Graveyard", 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),
|
||||
collapsed_by_default=True),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
import time
|
||||
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.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -18,13 +23,26 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..history import GameSummary
|
||||
from ..history_stats import FILTER_FIELDS, Filter, Record, apply_filters, compute_stats, length_seconds
|
||||
from . import theme
|
||||
from .card_list import MANA_ROLE, ManaDelegate
|
||||
from .deck_view import card_frame
|
||||
|
||||
COLUMNS = ["When", "Event", "Opponent", "Game", "On the play", "You", "Them", "Result", "Turns", "Length"]
|
||||
COL_PLAY, COL_MINE, COL_THEIRS, COL_RESULT = 4, 5, 6, 7
|
||||
COLUMNS = ["When", "Event", "Deck", "Your commander", "Opponent", "Their commander", "Game", "On the play",
|
||||
"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
|
||||
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:
|
||||
@@ -37,18 +55,98 @@ def fmt_when(ms: int | None) -> str:
|
||||
return dt.strftime("%b %d %H:%M")
|
||||
|
||||
|
||||
def _fmt_length(g: GameSummary) -> str:
|
||||
if not g.started_ms or not g.ended_ms:
|
||||
def fmt_seconds(sec: float | None) -> str:
|
||||
if sec is None:
|
||||
return ""
|
||||
sec = max(0, (g.ended_ms - g.started_ms) // 1000)
|
||||
sec = int(sec)
|
||||
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):
|
||||
open_game = Signal(str, int) # match_id, game_number
|
||||
|
||||
def __init__(self, parent=None):
|
||||
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.setHorizontalHeaderLabels(COLUMNS)
|
||||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
@@ -59,69 +157,200 @@ class HistoryView(QWidget):
|
||||
self.table.setMouseTracking(True)
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self._context_menu)
|
||||
hdr = self.table.horizontalHeader()
|
||||
hdr.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
# User-resizable columns; widths are remembered (see _restore_or_size_columns).
|
||||
hdr.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
hdr.setStretchLastSection(False)
|
||||
hdr.setMinimumSectionSize(40)
|
||||
hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
for col in (COL_MINE, COL_THEIRS):
|
||||
self.table.setItemDelegateForColumn(col, ManaDelegate(self.table))
|
||||
hdr.setSortIndicatorShown(True)
|
||||
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.cellActivated.connect(self._clicked)
|
||||
|
||||
self.summary = QLabel()
|
||||
self.summary.setObjectName("muted")
|
||||
self.empty = QLabel("No games recorded yet. Finished matches show up here.")
|
||||
self.empty = QLabel()
|
||||
self.empty.setObjectName("muted")
|
||||
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(self.summary, 1)
|
||||
hint = QLabel("Click a game to open it")
|
||||
hint = QLabel("Click a game to open it · right-click to filter · click a header to sort")
|
||||
hint.setObjectName("muted")
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(self.chips_bar, 1)
|
||||
top.addWidget(hint)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addLayout(tiles)
|
||||
lay.addLayout(top)
|
||||
lay.addWidget(card_frame("Games played", self.table), 1)
|
||||
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)
|
||||
if item is None:
|
||||
return
|
||||
match_id, game_number, replayable = item.data(KEY_ROLE)
|
||||
if replayable:
|
||||
self.open_game.emit(match_id, game_number)
|
||||
return None
|
||||
match_id, game_number = item.data(KEY_ROLE)
|
||||
return next((g for g in self.shown if g.match_id == match_id and g.game_number == game_number), None)
|
||||
|
||||
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:
|
||||
self.empty.setVisible(not games)
|
||||
wins = sum(g.result == "Won" for g in games)
|
||||
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 "")
|
||||
)
|
||||
self.all_games = games
|
||||
self._refresh()
|
||||
|
||||
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))
|
||||
for r, g in enumerate(games):
|
||||
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 "?",
|
||||
game_label, "", "", "", g.result or "–", str(g.turns or ""), _fmt_length(g)]
|
||||
values = [fmt_when(g.started_ms), (g.event_id or "").replace("_", " "), g.deck_name or "",
|
||||
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):
|
||||
item = QTableWidgetItem(v)
|
||||
item = SortItem(v)
|
||||
item.setData(SORT_ROLE, sort_key(g, c))
|
||||
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:
|
||||
item.setForeground(QColor(theme.MUTED))
|
||||
item.setToolTip("Details for this game were pruned from the history cache")
|
||||
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)
|
||||
if g.on_play == "me":
|
||||
@@ -145,3 +374,11 @@ class HistoryView(QWidget):
|
||||
res.setForeground(QColor(theme.MINT_TEXT))
|
||||
elif g.result == "Lost":
|
||||
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)
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
QButtonGroup,
|
||||
QFileDialog,
|
||||
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -38,8 +39,8 @@ 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
|
||||
VIEWS = ["Match", "History"]
|
||||
MATCH, HISTORY = 0, 1
|
||||
|
||||
|
||||
class HistoryImport(QThread):
|
||||
@@ -52,17 +53,21 @@ class HistoryImport(QThread):
|
||||
self.path, self.store, self.cards = path, store, cards
|
||||
|
||||
def run(self) -> None:
|
||||
# Own card DB connection: this runs off the GUI thread.
|
||||
cards = CardDB(self.cards.path)
|
||||
saved = 0
|
||||
try:
|
||||
st = self.path.stat()
|
||||
except OSError:
|
||||
return
|
||||
st = None
|
||||
if st is not None:
|
||||
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))
|
||||
if self.store.meta(key) != stamp:
|
||||
saved = import_log(self.path, self.store, cards)
|
||||
self.store.set_meta(key, stamp)
|
||||
# Games recorded before commanders were stored.
|
||||
saved += self.store.backfill_commanders(cards)
|
||||
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.
|
||||
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.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.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):
|
||||
for page in (self.match_view, self.history_view):
|
||||
self.stack.addWidget(page)
|
||||
|
||||
# Top bar
|
||||
@@ -120,12 +129,19 @@ class MainWindow(QMainWindow):
|
||||
btn.setCheckable(True)
|
||||
btn.setShortcut(QKeySequence(f"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)
|
||||
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)
|
||||
# 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)
|
||||
self.customize = QToolButton()
|
||||
self.customize.setText("⚙ Customize")
|
||||
@@ -188,7 +204,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.resize(1400, 880)
|
||||
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:
|
||||
self.start_log(log_path, replay_rate)
|
||||
else:
|
||||
@@ -213,49 +229,38 @@ class MainWindow(QMainWindow):
|
||||
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
|
||||
quit_act.triggered.connect(self.close)
|
||||
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)
|
||||
btn.setMenu(menu)
|
||||
return btn
|
||||
|
||||
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]]
|
||||
return [self.my_sections, self.opp_sections, 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)
|
||||
self.toggles[1].fill_menu(menu) # match panels
|
||||
for title, tg in (("Turn stats", self.toggles[2]), ("My deck sections", self.my_sections),
|
||||
("Opponent sections", self.opp_sections), ("Preview", self.toggles[0])):
|
||||
sub = StayOpenMenu(title, 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)
|
||||
menu.addAction("Arrange sections and stats…").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()
|
||||
ArrangeDialog(self._arrangeable(), "Arrange sections and stats", 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.customize.setEnabled(index == MATCH)
|
||||
self.stack.setCurrentIndex(index)
|
||||
self.nav.button(index).setChecked(True)
|
||||
self._settings.setValue("view", index)
|
||||
self._settings.setValue("view2", index)
|
||||
self._force_refresh()
|
||||
|
||||
# -- menu actions ----------------------------------------------------------
|
||||
@@ -326,7 +331,7 @@ class MainWindow(QMainWindow):
|
||||
self.past_label.setText(text)
|
||||
self.past_bar.show()
|
||||
self.turn_view.selected = None
|
||||
self.show_view(0)
|
||||
self.show_view(MATCH)
|
||||
|
||||
def close_past_game(self) -> None:
|
||||
self.past = None
|
||||
@@ -364,7 +369,7 @@ class MainWindow(QMainWindow):
|
||||
self.refresh()
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self.stack.currentIndex() == 1:
|
||||
if self.stack.currentIndex() == MATCH:
|
||||
self.match_view.tick(self.shown_tracker, self.now_ms())
|
||||
|
||||
def refresh(self) -> None:
|
||||
@@ -376,11 +381,9 @@ class MainWindow(QMainWindow):
|
||||
self._rendered[index] = key
|
||||
self._render_header(t)
|
||||
self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview"))
|
||||
if index in (0, 1):
|
||||
if index == MATCH:
|
||||
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:
|
||||
@@ -397,6 +400,7 @@ class MainWindow(QMainWindow):
|
||||
if not t.in_match and t.last_result:
|
||||
parts.append(f"<b>Match {t.last_result.lower()}</b>")
|
||||
self.header.setText(" · ".join(parts))
|
||||
self.header.setToolTip(" · ".join(parts))
|
||||
|
||||
messages = []
|
||||
if not self.cards.available:
|
||||
|
||||
@@ -189,7 +189,7 @@ class PlayerCard(QFrame):
|
||||
class MatchView(QWidget):
|
||||
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)
|
||||
self.cards = cards
|
||||
self.toggles = toggles
|
||||
@@ -200,7 +200,7 @@ class MatchView(QWidget):
|
||||
|
||||
self.info = QFrame()
|
||||
self.info.setObjectName("card")
|
||||
self.turn_label = _label(name="bigNumber")
|
||||
self.turn_label = _label(name="statValue")
|
||||
self.turn_caption = _label(name="muted")
|
||||
self.phase = _label()
|
||||
self.score = _label()
|
||||
@@ -209,7 +209,7 @@ class MatchView(QWidget):
|
||||
self.duration = _label(name="muted")
|
||||
grid = QGridLayout(self.info)
|
||||
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]):
|
||||
grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
grid.setRowStretch(7, 1)
|
||||
@@ -221,19 +221,18 @@ class MatchView(QWidget):
|
||||
top_lay.addWidget(self.info, 2)
|
||||
top_lay.addWidget(self.opp, 3)
|
||||
|
||||
# The shared DeckView is re-parented into this slot when the match view is shown.
|
||||
self.deck_slot = QWidget()
|
||||
self.deck_slot_layout = QVBoxLayout(self.deck_slot)
|
||||
self.deck_slot_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
split = QSplitter(Qt.Orientation.Vertical)
|
||||
split.addWidget(top)
|
||||
split.addWidget(self.deck_slot)
|
||||
split.setChildrenCollapsible(False)
|
||||
split.setSizes([1, 1])
|
||||
# Players + match info, then turn stats, then the deck lists; heights are draggable.
|
||||
self.split = QSplitter(Qt.Orientation.Vertical)
|
||||
self.split.addWidget(top)
|
||||
self.split.addWidget(turn_view)
|
||||
self.split.addWidget(deck_view)
|
||||
self.split.setChildrenCollapsible(False)
|
||||
self.split.setStretchFactor(0, 2)
|
||||
self.split.setStretchFactor(1, 3)
|
||||
self.split.setStretchFactor(2, 5)
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(split)
|
||||
lay.addWidget(self.split)
|
||||
|
||||
def render(self, t: MatchTracker, now_ms: int) -> None:
|
||||
g = t.game
|
||||
@@ -246,8 +245,8 @@ class MatchView(QWidget):
|
||||
|
||||
on = self.toggles.on
|
||||
whose = "your turn" if g.active_player == g.my_seat else "their turn"
|
||||
self.turn_label.setText(str(g.turn or "–"))
|
||||
self.turn_caption.setText(whose if g.turn else "pre-game")
|
||||
self.turn_label.setText(f"Turn {g.turn}" if g.turn else "Pre-game")
|
||||
self.turn_caption.setText(whose if g.turn else "")
|
||||
phase = pretty_enum(g.phase) if g.phase else ""
|
||||
step = pretty_enum(g.step) if g.step else ""
|
||||
self.phase.setText(" · ".join(x for x in (phase, step) if x))
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QMouseEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from ..carddb import CardDB
|
||||
from ..images import ImageCache
|
||||
|
||||
|
||||
class CardPreview(QWidget):
|
||||
cleared = Signal()
|
||||
|
||||
def __init__(self, cards: CardDB, images: ImageCache, parent=None):
|
||||
super().__init__(parent)
|
||||
self.cards = cards
|
||||
@@ -26,18 +28,44 @@ class CardPreview(QWidget):
|
||||
self.caption.setWordWrap(True)
|
||||
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.setContentsMargins(4, 4, 4, 4)
|
||||
lay.addLayout(top)
|
||||
lay.addWidget(self.image, 1)
|
||||
lay.addWidget(self.caption)
|
||||
|
||||
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.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:
|
||||
if grp_id == self.grp_id:
|
||||
return
|
||||
self.grp_id = grp_id
|
||||
self.close_btn.setVisible(True)
|
||||
info = self.cards.get(grp_id)
|
||||
details = [info.type_line]
|
||||
if info.power or info.toughness:
|
||||
|
||||
@@ -18,6 +18,7 @@ DANGER = "#e07a6b"
|
||||
|
||||
FONT_PT = 13
|
||||
RADIUS = 10
|
||||
NAV_PADDING_X = 22
|
||||
|
||||
QSS = f"""
|
||||
* {{ outline: none; }}
|
||||
@@ -44,6 +45,12 @@ QFrame#card {{
|
||||
}}
|
||||
QLabel#cardTitle {{ color: {MINT_TEXT}; font-weight: bold; }}
|
||||
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}; }}
|
||||
QFrame#pastBar {{
|
||||
background: #1b2f29; border: 1px solid {MINT}; border-radius: {RADIUS}px;
|
||||
@@ -62,7 +69,7 @@ QPushButton:disabled, QToolButton:disabled {{ color: {MUTED}; }}
|
||||
QPushButton:checked, QToolButton:checked {{
|
||||
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; }}
|
||||
|
||||
QTreeWidget, QTableWidget, QListWidget {{
|
||||
|
||||
@@ -38,7 +38,7 @@ class StatsTable(QTableWidget):
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setShowGrid(False)
|
||||
self.setAlternatingRowColors(True)
|
||||
self.verticalHeader().setDefaultSectionSize(34)
|
||||
self.verticalHeader().setDefaultSectionSize(28)
|
||||
self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
|
||||
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;"
|
||||
@@ -77,41 +77,53 @@ class TurnView(QWidget):
|
||||
|
||||
self.prev_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.setCheckable(True)
|
||||
self.live_btn.setChecked(True)
|
||||
self.live_btn.setToolTip("Follow the current turn")
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("cardTitle")
|
||||
self.title.setObjectName("statValue")
|
||||
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.next_btn.clicked.connect(lambda: self._step(1))
|
||||
self.live_btn.toggled.connect(self._set_live)
|
||||
|
||||
nav = QHBoxLayout()
|
||||
nav.addStretch(1)
|
||||
nav.addWidget(self.prev_btn)
|
||||
nav.addWidget(self.title)
|
||||
nav.addWidget(self.next_btn)
|
||||
nav.addSpacing(12)
|
||||
nav.addWidget(self.live_btn)
|
||||
nav.addStretch(1)
|
||||
# Turn picker: sits between the two tables.
|
||||
arrows = QHBoxLayout()
|
||||
arrows.addWidget(self.prev_btn)
|
||||
arrows.addWidget(self.next_btn)
|
||||
picker = QWidget()
|
||||
picker_lay = QVBoxLayout(picker)
|
||||
picker_lay.setContentsMargins(0, 0, 0, 0)
|
||||
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.game_table = StatsTable()
|
||||
self.turn_frame = card_frame("Selected turn", self.turn_table)
|
||||
tables = QHBoxLayout()
|
||||
tables.addWidget(self.turn_frame, 1)
|
||||
tables.addWidget(card_frame("Whole game", self.game_table), 1)
|
||||
self.game_frame = card_frame("Whole game", self.game_table)
|
||||
|
||||
self.empty = QLabel("Waiting for a game…")
|
||||
self.empty = QLabel("Turn stats appear once a game starts.")
|
||||
self.empty.setObjectName("muted")
|
||||
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addLayout(nav)
|
||||
lay.addLayout(tables, 1)
|
||||
lay.addWidget(self.turn_frame, 1)
|
||||
lay.addWidget(self.picker_frame)
|
||||
lay.addWidget(self.game_frame, 1)
|
||||
lay.addWidget(self.empty, 1)
|
||||
|
||||
def _turns(self, g: GameState) -> list[int]:
|
||||
@@ -145,8 +157,7 @@ class TurnView(QWidget):
|
||||
g = t.game
|
||||
has_game = g is not None
|
||||
self.empty.setVisible(not has_game)
|
||||
for w in (self.turn_table, self.game_table, self.turn_frame, self.prev_btn, self.next_btn,
|
||||
self.live_btn, self.title):
|
||||
for w in (self.turn_frame, self.picker_frame, self.game_frame):
|
||||
w.setVisible(has_game)
|
||||
if g is None:
|
||||
return
|
||||
@@ -156,8 +167,9 @@ class TurnView(QWidget):
|
||||
self.selected = None
|
||||
shown = self.selected if self.selected is not None else g.turn
|
||||
active = g.turns.active.get(shown)
|
||||
whose = "yours" if active == g.my_seat else "theirs" if active else ""
|
||||
self.title.setText(f"Turn {shown}" + (f" ({whose})" if whose else "") if shown else "Pre-game")
|
||||
whose = "your" if active == g.my_seat else "their" if active else ""
|
||||
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.next_btn.setEnabled(self.selected is not None)
|
||||
|
||||
|
||||
@@ -225,3 +225,25 @@ def test_commanders_are_not_emblems():
|
||||
assert g.commander_status(OPP) == [(9000, "stack")]
|
||||
assert g.commander_tax(OPP) == 2
|
||||
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
|
||||
@@ -65,3 +65,36 @@ def test_recorder_ignores_unfinished_match(tmp_path):
|
||||
t.handle(ev)
|
||||
rec.observe(ev, t)
|
||||
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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user