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