From 5b3c8c8e6312da67de48e2473970c16885de83e8 Mon Sep 17 00:00:00 2001 From: Aeth3r Date: Fri, 16 Oct 2026 11:18:44 -0400 Subject: [PATCH] Add match, turn and history views with a dark theme - Top bar switching between Deck, Match, Turn and History views - Match view: life, player counters, commanders, emblems, zone counts, turn timer and timeouts, land drops used/allowed, play/draw and Bo3 draw counts, with the deck view below - Turn view: per-player and total stats per turn with history, plus whole-game totals and land drops - History: every finished game (colours, winner, who was on the play); click to reopen it in the other views; imports Player-prev.log - Customize menu to show/hide items and an Arrange dialog to reorder deck sections and turn stats, remembered across sessions - Static dark theme with mint accent, rounded corners, larger text - Commanders tracked separately from emblems, with location and tax - Quieter, non-sticky warning for cards lost to MTGA log gaps - Correct Scryfall images for tokens Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +- src/mtga_assistant/app.py | 12 +- src/mtga_assistant/carddb.py | 71 +++- src/mtga_assistant/gamestate.py | 249 +++++++++++++- src/mtga_assistant/history.py | 279 ++++++++++++++++ src/mtga_assistant/images.py | 18 +- src/mtga_assistant/logparse.py | 7 +- src/mtga_assistant/paths.py | 4 + src/mtga_assistant/turnstats.py | 162 +++++++++ src/mtga_assistant/ui/arrange.py | 106 ++++++ src/mtga_assistant/ui/card_list.py | 9 +- src/mtga_assistant/ui/deck_view.py | 170 ++++++++++ src/mtga_assistant/ui/history_view.py | 147 ++++++++ src/mtga_assistant/ui/main_window.py | 464 +++++++++++++++++--------- src/mtga_assistant/ui/match_view.py | 287 ++++++++++++++++ src/mtga_assistant/ui/theme.py | 136 ++++++++ src/mtga_assistant/ui/toggles.py | 98 ++++++ src/mtga_assistant/ui/turn_view.py | 187 +++++++++++ tests/test_gamestate.py | 31 ++ tests/test_history.py | 67 ++++ tests/test_toggles.py | 41 +++ tests/test_turnstats.py | 214 ++++++++++++ 22 files changed, 2599 insertions(+), 193 deletions(-) create mode 100644 src/mtga_assistant/history.py create mode 100644 src/mtga_assistant/turnstats.py create mode 100644 src/mtga_assistant/ui/arrange.py create mode 100644 src/mtga_assistant/ui/deck_view.py create mode 100644 src/mtga_assistant/ui/history_view.py create mode 100644 src/mtga_assistant/ui/match_view.py create mode 100644 src/mtga_assistant/ui/theme.py create mode 100644 src/mtga_assistant/ui/toggles.py create mode 100644 src/mtga_assistant/ui/turn_view.py create mode 100644 tests/test_history.py create mode 100644 tests/test_toggles.py create mode 100644 tests/test_turnstats.py diff --git a/README.md b/README.md index 9907f0b..fda36f8 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,31 @@ 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: -- **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). +Switch views with the buttons in the top bar (or Ctrl+1/2/3): + +- **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. + +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. Card names, mana costs and types come from MTGA's own local card database, so they work offline. @@ -45,8 +65,11 @@ Optional: `~/.config/mtga-assistant/config.toml` log_path = "/path/to/Player.log" # override auto-detection mtga_dir = "/path/to/steamapps/common/MTGA" cache_max_mb = 500 # image cache cap +history_max_mb = 300 # cap for stored game details (summaries are always kept) ``` +Game history lives in `~/.local/share/mtga-assistant/history.db` (roughly 20–100 KB per game). + ## Image cache Images are downloaded only for cards that actually appear, at Scryfall's `normal` size (about diff --git a/src/mtga_assistant/app.py b/src/mtga_assistant/app.py index 1798a5f..9fcbca1 100644 --- a/src/mtga_assistant/app.py +++ b/src/mtga_assistant/app.py @@ -10,7 +10,9 @@ from PySide6.QtWidgets import QApplication from . import paths from .carddb import CardDB +from .history import HistoryStore from .images import ImageCache +from .ui import theme from .ui.main_window import MainWindow @@ -31,16 +33,22 @@ def main() -> int: app = QApplication([sys.argv[0], *qt_args]) app.setApplicationName("MTGA Assistant") app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules + theme.apply(app) mtga_dir = paths.find_mtga_dir(cfg) db_path = paths.find_card_db(mtga_dir) if mtga_dir else None cards = CardDB(db_path) images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024) + history = HistoryStore(paths.DATA_DIR / "history.db", cfg.history_max_mb) + log_path = paths.find_log_path(cfg) if args.replay: - win = MainWindow(cards, images, args.replay, replay_rate=args.rate) + win = MainWindow(cards, images, history, args.replay, replay_rate=args.rate) else: - win = MainWindow(cards, images, paths.find_log_path(cfg)) + win = MainWindow(cards, images, history, log_path) + if log_path is not None: + # Matches from the previous MTGA session are only in Player-prev.log. + win.import_history(log_path.with_name("Player-prev.log")) app.aboutToQuit.connect(win.shutdown) win.show() return app.exec() diff --git a/src/mtga_assistant/carddb.py b/src/mtga_assistant/carddb.py index 37ffdb0..a4999d9 100644 --- a/src/mtga_assistant/carddb.py +++ b/src/mtga_assistant/carddb.py @@ -13,6 +13,8 @@ CARD_TYPES = { 6: "Phenomenon", 7: "Plane", 8: "Planeswalker", 9: "Scheme", 10: "Sorcery", 11: "Kindred", 12: "Vanguard", 13: "Dungeon", 14: "Battle", 15: "Conspiracy", } +COLORS = {1: "W", 2: "U", 3: "B", 4: "R", 5: "G"} +WUBRG = "WUBRG" RARITIES = {0: "token", 1: "land", 2: "common", 3: "uncommon", 4: "rare", 5: "mythic"} # Cards.LinkedFaceType values whose first LinkedFaceGrpId is the "base" card: DFC back @@ -20,6 +22,19 @@ RARITIES = {0: "token", 1: "land", 2: "common", 3: "uncommon", 4: "rare", 5: "my # (Observed: odd types are the derived face, except omens where 20 is the derived side.) DERIVED_FACE_TYPES = {1, 3, 5, 7, 9, 11, 13, 15, 17, 20} +_EXTRA_LANDS = re.compile(r"play (an|one|two|three|X) additional lands?", re.IGNORECASE) +_NUMBERS = {"an": 1, "one": 1, "two": 2, "three": 3, "x": 1} + + +@dataclass(frozen=True) +class LandRule: + """An ability letting a player play extra lands.""" + + extra: int + this_turn: bool # one-shot ("…this turn") rather than static ("…on each of your turns") + all_players: bool # "Each player may play an additional land…" + + _MANA_TOKEN = re.compile(r"o(\([^)]*\)|[^o(]+)") @@ -54,6 +69,7 @@ class CardInfo: toughness: str = "" linked_faces: tuple[int, ...] = field(default=()) linked_face_type: int = 0 + colors: tuple[str, ...] = () # subset of W U B R G, in WUBRG order @property def mana_value(self) -> int: @@ -95,14 +111,15 @@ class CardDB: (SELECT Loc FROM Localizations_enUS l WHERE l.LocId = c.TitleId ORDER BY l.Formatted = 1 DESC, l.Formatted LIMIT 1), c.OldSchoolManaText, c.Types, c.ExpansionCode, c.CollectorNumber, - c.Rarity, c.IsToken, c.Power, c.Toughness, c.LinkedFaceGrpIds, c.LinkedFaceType + c.Rarity, c.IsToken, c.Power, c.Toughness, c.LinkedFaceGrpIds, c.LinkedFaceType, c.Colors FROM Cards c WHERE c.GrpId = ? """, (grp_id,), ).fetchone() if row is None: return unknown_card(grp_id) - (gid, name, mana, types, set_code, cn, rarity, is_token, power, tough, linked, ltype) = row + (gid, name, mana, types, set_code, cn, rarity, is_token, power, tough, linked, ltype, colors) = row + color_set = {COLORS.get(int(c)) for c in (colors or "").split(",") if c.strip().isdigit()} return CardInfo( grp_id=gid, name=_strip_tags(name) if name else f"Card #{gid}", @@ -116,8 +133,58 @@ class CardDB: toughness=tough or "", linked_faces=tuple(int(x) for x in (linked or "").split(",") if x), linked_face_type=int(ltype or 0), + colors=tuple(c for c in WUBRG if c in color_set), ) + @lru_cache(maxsize=None) + def counter_name(self, counter_type: int) -> str: + if self._conn is not None: + row = self._conn.execute( + """ + SELECT l.Loc FROM Enums e JOIN Localizations_enUS l ON l.LocId = e.LocId + WHERE e.Type = 'CounterType' AND e.Value = ? ORDER BY l.Formatted = 1 DESC LIMIT 1 + """, + (counter_type,), + ).fetchone() + if row and row[0]: + return _strip_tags(row[0]) + return f"Counter #{counter_type}" + + @lru_cache(maxsize=None) + def ability_ids(self, grp_id: int) -> tuple[int, ...]: + if self._conn is None: + return () + row = self._conn.execute("SELECT AbilityIds FROM Cards WHERE GrpId = ?", (grp_id,)).fetchone() + # Format: "abilityId:textId,abilityId:textId,..." + return tuple(int(part.split(":")[0]) for part in (row[0] if row else "").split(",") if part) + + @lru_cache(maxsize=1) + def land_rules(self) -> dict[int, LandRule]: + """Ability id -> extra land plays it grants, found from the abilities' rules text.""" + if self._conn is None: + return {} + rows = self._conn.execute( + """ + SELECT a.Id, l.Loc FROM Abilities a JOIN Localizations_enUS l ON l.LocId = a.TextId + WHERE l.Loc LIKE '%additional land%' + """ + ).fetchall() + rules: dict[int, LandRule] = {} + for ability_id, text in rows: + text = _strip_tags(text or "") + # Level/station wrappers grant the real ability separately once reached. + if text.startswith(("CLASSLEVEL", "STATIONLEVEL")): + continue + m = _EXTRA_LANDS.search(text) + if not m: + continue + rules[ability_id] = LandRule( + extra=_NUMBERS[m.group(1).lower()], + this_turn="this turn" in text[m.end():m.end() + 20].lower(), + all_players=text.lower().startswith("each player"), + ) + return rules + def base_grp(self, grp_id: int) -> int: """Canonical grpId for a physical card, e.g. a DFC's back face -> its front face.""" info = self.get(grp_id) diff --git a/src/mtga_assistant/gamestate.py b/src/mtga_assistant/gamestate.py index 52daeca..89e3a76 100644 --- a/src/mtga_assistant/gamestate.py +++ b/src/mtga_assistant/gamestate.py @@ -21,9 +21,11 @@ from dataclasses import dataclass, field from typing import Any from .logparse import ClientDeck, DeckSubmitted, Event, GreMessages, MatchRoom +from .turnstats import TurnLog HIDDEN_ZONES = {"ZoneType_Library", "ZoneType_Hand"} CARD_OBJECT_TYPES = {"GameObjectType_Card"} +TURN_TIMER_TYPES = {"TimerType_ActivePlayer", "TimerType_NonActivePlayer"} # Zones whose cards are "out of the library" for my remaining-library maths. MY_TRACKED_ZONES = ( "ZoneType_Hand", @@ -65,6 +67,7 @@ class GameState: turn: int = 0 active_player: int = 0 phase: str = "" + step: str = "" complete: bool = False applied_state_ids: set[int] = field(default_factory=set) # MTGA omits oversized GRE messages from the log ("Message summarized..."); after such a @@ -79,13 +82,52 @@ class GameState: # because an object can be logged a message before the ObjectIdChanged that links it. opp_seen: dict[int, set[int]] = field(default_factory=dict) + # Match-view data. + timers: dict[int, dict[str, Any]] = field(default_factory=dict) # timerId -> timer + timer_ts: dict[int, int] = field(default_factory=dict) # timerId -> message time (ms) + timer_seat: dict[int, int] = field(default_factory=dict) # timerId -> seat + max_timeouts: int = 0 + best_of: int = 1 + timeouts_used: dict[int, int] = field(default_factory=dict) + player_counters: dict[int, Counter[int]] = field(default_factory=dict) # seat -> counter type -> n + started_ms: int | None = None + last_ms: int | None = None + turns: TurnLog = field(default_factory=TurnLog) + # Commander/Brawl: seat -> commander grpIds (mine from the deck message, anyone's from cards + # seen in their command zone), and how often each seat cast a commander from there (for tax). + commanders: dict[int, set[int]] = field(default_factory=dict) + commander_casts: Counter[int] = field(default_factory=Counter) + # Optional CardDB, used to find abilities granting extra land plays. + cards: Any = None + extra_lands_this_turn: Counter[tuple[int, int]] = field(default_factory=Counter) # (turn, seat) + @property def opp_seat(self) -> int: return 2 if self.my_seat == 1 else 1 + @property + def seats(self) -> set[int]: + return {self.my_seat, self.opp_seat} + # -- applying messages ------------------------------------------------- - def apply(self, gsm: dict[str, Any]) -> None: + def note_time(self, ts: int | None) -> None: + if ts is None: + return + if self.started_ms is None: + self.started_ms = ts + self.last_ms = max(self.last_ms or ts, ts) + + def apply_timers(self, timers: list[dict[str, Any]], ts: int | None) -> None: + for t in timers: + tid = t.get("timerId") + if tid is None: + continue + self.timers[tid] = t + if ts is not None: + self.timer_ts[tid] = ts + + def apply(self, gsm: dict[str, Any], ts: int | None = None) -> None: state_id = gsm.get("gameStateId") if state_id is not None: if state_id in self.applied_state_ids: @@ -100,8 +142,12 @@ class GameState: and prev not in self.applied_state_ids: self.desynced = True + self.note_time(ts) if info := gsm.get("gameInfo"): self.game_number = info.get("gameNumber", self.game_number) + self.max_timeouts = info.get("maxTimeoutCount", self.max_timeouts) + if "matchWinCondition" in info: + self.best_of = 3 if "Best2of3" in info["matchWinCondition"] else 1 if info.get("stage") == "GameStage_GameOver" or info.get("matchState") in ( "MatchState_GameComplete", "MatchState_MatchComplete", @@ -109,16 +155,31 @@ class GameState: self.complete = True for p in gsm.get("players") or []: seat = p.get("systemSeatNumber") - if seat is not None and "lifeTotal" in p: + if seat is None: + continue + if "lifeTotal" in p: self.life[seat] = p["lifeTotal"] + for tid in p.get("timerIds") or []: + self.timer_seat[tid] = seat + if "timeoutCount" in p: + self.timeouts_used[seat] = p["timeoutCount"] if ti := gsm.get("turnInfo"): self.turn = ti.get("turnNumber", self.turn) self.active_player = ti.get("activePlayer", self.active_player) - self.phase = ti.get("phase", self.phase) + if "phase" in ti: + self.phase = ti["phase"] + self.step = ti.get("step", "") + if self.active_player: + self.turns.start_turn(self.turn, self.active_player) + self.apply_timers(gsm.get("timers") or [], ts) - for ann in gsm.get("annotations") or []: + annotations = gsm.get("annotations") or [] + for ann in annotations: self._apply_annotation(ann) + for ann in gsm.get("persistentAnnotations") or []: + self._apply_persistent(ann) + zones_before = dict(self.zones) for z in gsm.get("zones") or []: zid = z["zoneId"] self.zones[zid] = Zone(zid, z.get("type", ""), z.get("ownerSeatId"), list(z.get("objectInstanceIds") or [])) @@ -126,6 +187,10 @@ class GameState: for obj in gsm.get("gameObjects") or []: self._apply_object(obj) + self.turns.record(self.turn, annotations, zones_before, self.zones, self.objects, self.seats) + self._track_commanders(annotations, zones_before) + self._track_land_plays(annotations) + for iid in gsm.get("diffDeletedInstanceIds") or []: self.objects.pop(iid, None) @@ -143,6 +208,25 @@ class GameState: for old in d.get("OldIds") or []: if old in self.known: self.opp_library_unplaced[self.known.pop(old)] += 1 + added = "AnnotationType_CounterAdded" in types + if added or "AnnotationType_CounterRemoved" in types: + d = _details(ann) + if d.get("counter_type"): + amount = (d.get("transaction_amount") or [1])[0] + for seat in ann.get("affectedIds") or []: + if seat in self.seats: + c = self.player_counters.setdefault(seat, Counter()) + c[d["counter_type"][0]] += amount if added else -amount + c[d["counter_type"][0]] = max(0, c[d["counter_type"][0]]) + + def _apply_persistent(self, ann: dict[str, Any]) -> None: + # Persistent "Counter" annotations carry the absolute count for players. + if "AnnotationType_Counter" in (ann.get("type") or []): + d = _details(ann) + if d.get("counter_type") and d.get("count"): + for seat in ann.get("affectedIds") or []: + if seat in self.seats: + self.player_counters.setdefault(seat, Counter())[d["counter_type"][0]] = d["count"][0] def _apply_object(self, obj: dict[str, Any]) -> None: iid = obj["instanceId"] @@ -173,6 +257,132 @@ class GameState: # -- queries ------------------------------------------------------------ + def turn_timer(self, seat: int, now_ms: int) -> tuple[float, float, bool] | None: + """(seconds remaining, warning threshold, running) for the seat's turn timer. + + Arena only starts these timers when a player stalls (the rope), so most of the time this + reports the time the player has available for their current role without counting down. + """ + role = "TimerType_ActivePlayer" if self.active_player == seat else "TimerType_NonActivePlayer" + mine = [(tid, t) for tid, t in self.timers.items() + if self.timer_seat.get(tid) == seat and t.get("type") in TURN_TIMER_TYPES] + running = [(tid, t) for tid, t in mine if t.get("running")] + chosen = running or [(tid, t) for tid, t in mine if t.get("type") == role] + if not chosen: + return None + tid, t = chosen[0] + remaining = t.get("durationSec", 0) - t.get("elapsedMs", 0) / 1000 + if t.get("running"): + remaining -= max(0, now_ms - self.timer_ts.get(tid, now_ms)) / 1000 + return max(0.0, remaining), float(t.get("warningThresholdSec", 0)), bool(t.get("running")) + + def _track_commanders(self, annotations: list[dict[str, Any]], zones_before: dict[int, Zone]) -> None: + for z in self.zones.values(): + if z.type != "ZoneType_Command": + continue + for iid in z.ids: + obj = self.objects.get(iid) + # Cards (not emblems) in the command zone are commanders. + if obj and obj.get("grpId") and obj.get("type") == "GameObjectType_Card" and obj.get("ownerSeatId"): + self.commanders.setdefault(obj["ownerSeatId"], set()).add(obj["grpId"]) + for ann in annotations: + if "AnnotationType_ZoneTransfer" not in (ann.get("type") or []): + continue + d = _details(ann) + src = zones_before.get((d.get("zone_src") or [None])[0]) or self.zones.get((d.get("zone_src") or [None])[0]) + if src and src.type == "ZoneType_Command" and (d.get("category") or [""])[0] == "CastSpell": + obj = self.objects.get((ann.get("affectedIds") or [None])[0]) or {} + if seat := obj.get("ownerSeatId"): + self.commander_casts[seat] += 1 + + def emblems(self, seat: int) -> Counter[int]: + """Emblems and other non-card objects in the seat's command zone (commanders excluded).""" + out: Counter[int] = Counter() + for z in self.zones.values(): + if z.type != "ZoneType_Command": + continue + for iid in z.ids: + obj = self.objects.get(iid) + if obj and obj.get("grpId") and obj.get("type") != "GameObjectType_Card" \ + and obj.get("ownerSeatId", obj.get("controllerSeatId")) == seat: + out[obj["grpId"]] += 1 + return out + + def _land_rules(self) -> dict: + return self.cards.land_rules() if self.cards is not None and hasattr(self.cards, "land_rules") else {} + + def _track_land_plays(self, annotations: list[dict[str, Any]]) -> None: + rules = self._land_rules() + if rules: + for ann in annotations: + if "AnnotationType_ResolutionComplete" not in (ann.get("type") or []): + continue + grp = (_details(ann).get("grpid") or [0])[0] + # Abilities resolve under their own id; spells under their card's grpId. + ids = (grp,) if grp in rules else self.cards.ability_ids(grp) + extra = sum(rules[a].extra for a in ids if a in rules and rules[a].this_turn) + if extra: + obj = self.objects.get(ann.get("affectorId")) or {} + seat = obj.get("controllerSeatId") or obj.get("ownerSeatId") or self.active_player + self.extra_lands_this_turn[(self.turn, seat)] += extra + if self.turn > 0 and self.active_player: + allowed = self.land_allowance(self.active_player) + self.turns.land_allowed[self.turn] = max(self.turns.land_allowed.get(self.turn, 0), allowed) + + def land_allowance(self, seat: int) -> int: + """Land plays the seat gets this turn: one, plus extras from permanents/emblems and effects.""" + allowed = 1 + self.extra_lands_this_turn[(self.turn, seat)] + rules = self._land_rules() + if not rules: + return allowed + for z in self.zones.values(): + if z.type not in ("ZoneType_Battlefield", "ZoneType_Command"): + continue + for iid in z.ids: + obj = self.objects.get(iid) + if not obj or not obj.get("grpId"): + continue + abilities = [a.get("grpId") for a in obj.get("uniqueAbilities") or []] \ + or self.cards.ability_ids(obj["grpId"]) + controller = obj.get("controllerSeatId") or obj.get("ownerSeatId") + for a in abilities: + rule = rules.get(a) + if rule and not rule.this_turn and (rule.all_players or controller == seat): + allowed += rule.extra + return allowed + + def land_drop(self, seat: int) -> tuple[int, int] | None: + """(lands played, allowed) this turn for the seat, or None when it isn't their turn.""" + if seat != self.active_player or self.turn <= 0: + return None + used = self.turns.turn(self.turn).get("lands", Counter()).get(seat, 0) + return used, self.land_allowance(seat) + + @property + def on_play(self) -> int | None: + """Seat that took the first turn (was on the play).""" + return self.turns.active.get(1) + + def commander_tax(self, seat: int) -> int: + return 2 * self.commander_casts[seat] + + def commander_status(self, seat: int) -> list[tuple[int, str]]: + """(grpId, where it is now) for each of the seat's commanders; location '' if hidden.""" + out = [] + for grp in sorted(self.commanders.get(seat, ())): + where = "" + for z in self.zones.values(): + if z.type == "ZoneType_Limbo": # holds stale copies of objects that changed id + continue + if any((o := self.objects.get(i)) and o.get("grpId") == grp and o.get("ownerSeatId") == seat + and o.get("type") == "GameObjectType_Card" for i in z.ids): + where = z.type.removeprefix("ZoneType_").lower() + if where == "command": + where = "command zone" + break + out.append((grp, where)) + return out + def zone_of_type(self, ztype: str, owner: int | None) -> Zone | None: for z in self.zones.values(): if z.type == ztype and (owner is None or z.owner == owner): @@ -252,16 +462,20 @@ class MatchTracker: game_results: list[str] = field(default_factory=list) # "Won"/"Lost" per finished game last_result: str = "" next_deck: ClientDeck | None = None + on_play: dict[int, int] = field(default_factory=dict) # game number -> seat on the play # Opponent cards seen in earlier games of this match (max count in any one game). opp_seen_prev_games: Counter[int] = field(default_factory=Counter) deck_name: str = "" submitted_deck: DeckSubmitted | None = None version: int = 0 # bumped on every change, for cheap UI refresh checks + last_ms: int | None = None # newest message time seen (the clock for replays) def handle(self, ev: Event) -> None: if isinstance(ev, GreMessages): for m in ev.messages: - self._handle_gre(m) + self._handle_gre(m, ev.timestamp_ms) + if ev.timestamp_ms is not None: + self.last_ms = max(self.last_ms or 0, ev.timestamp_ms) elif isinstance(ev, MatchRoom): self._handle_room(ev) elif isinstance(ev, DeckSubmitted): @@ -272,6 +486,7 @@ class MatchTracker: self.version += 1 def _start_match(self) -> None: + self.on_play.clear() self.opp_seen_prev_games.clear() self.game_results.clear() self.last_result = "" @@ -297,8 +512,11 @@ class MatchTracker: if self.game: self.game.complete = True - def _handle_gre(self, m: dict[str, Any]) -> None: + def _handle_gre(self, m: dict[str, Any], ts: int | None = None) -> None: mtype = m.get("type") + if mtype == "GREMessageType_TimerStateMessage" and self.game is not None: + self.game.apply_timers((m.get("timerStateMessage") or {}).get("timers") or [], ts) + return if mtype == "GREMessageType_ConnectResp": seats = m.get("systemSeatIds") or [1] self.my_seat = seats[0] @@ -307,9 +525,11 @@ class MatchTracker: self._start_match() self.next_deck = None self.game = GameState( + cards=self.card_lookup, my_seat=self.my_seat, deck=Counter(deck_msg.get("deckCards") or []), sideboard=Counter(deck_msg.get("sideboardCards") or []), + commanders={self.my_seat: set(deck_msg["commanderCards"])} if deck_msg.get("commanderCards") else {}, ) return gsm = m.get("gameStateMessage") @@ -322,9 +542,12 @@ class MatchTracker: self.opp_seen_prev_games = self.opp_seen_match deck = self.next_deck or ClientDeck(prev.deck, prev.sideboard) self.next_deck = None - self.game = GameState(my_seat=prev.my_seat, deck=Counter(deck.main), - sideboard=Counter(deck.sideboard), game_number=game_no) - self.game.apply(gsm) + self.game = GameState(cards=self.card_lookup, my_seat=prev.my_seat, deck=Counter(deck.main), + sideboard=Counter(deck.sideboard), game_number=game_no, + commanders={k: set(v) for k, v in prev.commanders.items()}) + self.game.apply(gsm, ts) + if self.game.on_play is not None: + self.on_play.setdefault(self.game.game_number, self.game.on_play) results = (gsm.get("gameInfo") or {}).get("results") or [] game_results = [r for r in results if r.get("scope") == "MatchScope_Game"] if len(game_results) > len(self.game_results) and self.my_seat is not None: @@ -359,6 +582,14 @@ class MatchTracker: remaining.subtract(g.my_outside_library(self.normalize)) return +remaining # drop zero/negative entries + def games_on_draw(self) -> dict[int, int]: + """Seat -> number of games this match it was on the draw.""" + out: dict[int, int] = {} + for seat in self.on_play.values(): + other = 2 if seat == 1 else 1 + out[other] = out.get(other, 0) + 1 + return out + @property def opponent_name(self) -> str: if self.my_seat is None: diff --git a/src/mtga_assistant/history.py b/src/mtga_assistant/history.py new file mode 100644 index 0000000..b669e68 --- /dev/null +++ b/src/mtga_assistant/history.py @@ -0,0 +1,279 @@ +"""Game history: per-game summaries plus each match's events, so past games can be re-opened. + +Matches are stored when they complete, in ``/history.db`` (SQLite): + +* ``games``: one summary row per game (opponent, colours, winner, who was on the play, ...). +* ``matches``: the match's events, gzipped JSON. Opening a past game replays them through a fresh + ``MatchTracker`` up to the end of that game, so every view works for it. +""" + +from __future__ import annotations + +import gzip +import json +import sqlite3 +import threading +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .gamestate import GameState, MatchTracker +from .logparse import ClientDeck, DeckSubmitted, Event, GreMessages, LogParser, MatchRoom + +# GRE message types the tracker uses; everything else is dropped to keep storage small. +KEPT_GRE_TYPES = { + "GREMessageType_ConnectResp", + "GREMessageType_GameStateMessage", + "GREMessageType_QueuedGameStateMessage", + "GREMessageType_TimerStateMessage", +} + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS 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, -- 'me' | 'opp' | '' + result TEXT, -- 'Won' | 'Lost' | '' + my_colors TEXT, -- e.g. 'UB' + opp_colors TEXT, + turns INTEGER, + best_of INTEGER, + 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 meta (key TEXT PRIMARY KEY, value TEXT); +""" + + +@dataclass +class GameSummary: + match_id: str + game_number: int + started_ms: int | None + ended_ms: int | None + event_id: str + opponent: str + deck_name: str + on_play: str + result: str + my_colors: str + opp_colors: str + turns: int + best_of: int + replayable: bool = True + + +# -- event (de)serialisation ------------------------------------------------------ + + +def encode(ev: Event) -> dict[str, Any] | None: + if isinstance(ev, GreMessages): + msgs = [] + for m in ev.messages: + if m.get("type") not in KEPT_GRE_TYPES: + continue + if gsm := m.get("gameStateMessage"): + # Available actions are large and unused by the tracker. + m = {**m, "gameStateMessage": {k: v for k, v in gsm.items() if k != "actions"}} + msgs.append(m) + return {"k": "gre", "m": msgs, "ts": ev.timestamp_ms} if msgs else None + if isinstance(ev, MatchRoom): + return {"k": "room", "state": ev.state, "match_id": ev.match_id, "players": ev.players, + "result": ev.final_result} + if isinstance(ev, DeckSubmitted): + return {"k": "deck", "name": ev.name, "event": ev.event, + "main": list(ev.main.items()), "side": list(ev.sideboard.items())} + if isinstance(ev, ClientDeck): + return {"k": "cdeck", "main": list(ev.main.items()), "side": list(ev.sideboard.items())} + return None + + +def decode(d: dict[str, Any]) -> Event | None: + k = d.get("k") + if k == "gre": + return GreMessages(d["m"], d.get("ts")) + if k == "room": + return MatchRoom(d["state"], d["match_id"], d["players"], d.get("result")) + if k == "deck": + return DeckSubmitted(d["name"], d["event"], Counter(dict(d["main"])), Counter(dict(d["side"]))) + if k == "cdeck": + return ClientDeck(Counter(dict(d["main"])), Counter(dict(d["side"]))) + return None + + +# -- storage -------------------------------------------------------------------------- + + +class HistoryStore: + def __init__(self, path: Path, max_events_mb: int = 300): + path.parent.mkdir(parents=True, exist_ok=True) + self.path = path + self.max_events_bytes = max_events_mb * 1024 * 1024 + self._lock = threading.Lock() + self._conn = sqlite3.connect(path, check_same_thread=False) + self._conn.executescript(SCHEMA) + self._conn.commit() + + def has_match(self, match_id: str) -> bool: + with self._lock: + return self._conn.execute("SELECT 1 FROM games WHERE match_id = ? LIMIT 1", (match_id,)).fetchone() is not None + + def save_match(self, match_id: str, games: list[GameSummary], events: list[dict[str, Any]]) -> None: + blob = gzip.compress(json.dumps(events, separators=(",", ":")).encode()) + with self._lock: + self._conn.executemany( + "INSERT OR IGNORE INTO games VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + [(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], + ) + self._conn.execute("INSERT OR IGNORE INTO matches VALUES (?, ?)", (match_id, blob)) + self._prune() + self._conn.commit() + + def _prune(self) -> None: + """Drop the oldest matches' events (summaries stay) when over the size cap.""" + total = self._conn.execute("SELECT COALESCE(SUM(LENGTH(events)), 0) FROM matches").fetchone()[0] + if total <= self.max_events_bytes: + return + rows = self._conn.execute( + """SELECT m.match_id, LENGTH(m.events) FROM matches m + LEFT JOIN (SELECT match_id, MIN(started_ms) AS s FROM games GROUP BY match_id) g USING (match_id) + ORDER BY g.s""" + ).fetchall() + for match_id, size in rows: + if total <= self.max_events_bytes: + break + self._conn.execute("DELETE FROM matches WHERE match_id = ?", (match_id,)) + total -= size + + 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) + ORDER BY g.started_ms DESC, g.game_number DESC""" + ).fetchall() + return [GameSummary(*r[:13], replayable=bool(r[13])) for r in rows] + + def events(self, match_id: str) -> list[Event]: + with self._lock: + row = self._conn.execute("SELECT events FROM matches WHERE match_id = ?", (match_id,)).fetchone() + if row is None: + return [] + return [e for e in (decode(d) for d in json.loads(gzip.decompress(row[0]))) if e is not None] + + def meta(self, key: str) -> str | None: + with self._lock: + row = self._conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() + return row[0] if row else None + + def set_meta(self, key: str, value: str) -> None: + with self._lock: + self._conn.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (key, value)) + self._conn.commit() + + +# -- recording ------------------------------------------------------------------------ + + +def colors_of(cards, grp_ids) -> str: + found = set() + if cards is not None: + for grp in grp_ids: + info = cards.get(grp) + if not info.is_token: + found.update(getattr(info, "colors", ())) + return "".join(c for c in "WUBRG" if c in found) + + +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, ())) + opp_cards = list(g.opp_seen_counts(t.normalize)) + list(g.commanders.get(g.opp_seat, ())) + on_play = "" if g.on_play is None else ("me" if g.on_play == g.my_seat else "opp") + return GameSummary( + match_id=t.match_id, game_number=g.game_number, started_ms=g.started_ms, ended_ms=g.last_ms, + 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, + ) + + +class HistoryRecorder: + """Watches the event stream next to a MatchTracker and stores each match when it completes.""" + + def __init__(self, store: HistoryStore): + self.store = store + self.last_deck: dict[str, Any] | None = None + self.reset() + + def reset(self) -> None: + self.buffer: list[dict[str, Any]] | None = None + self.games: dict[int, GameState] = {} + + def observe(self, ev: Event, t: MatchTracker) -> bool: + """Call after ``t.handle(ev)``. Returns True when a match was saved.""" + enc = encode(ev) + if isinstance(ev, DeckSubmitted): + self.last_deck = enc + if self.buffer is None and t.in_match: + self.buffer = [self.last_deck] if self.last_deck else [] + self.games = {} + if self.buffer is None: + return False + if enc is not None and not isinstance(ev, DeckSubmitted): + self.buffer.append(enc) + if t.game is not None: + self.games[t.game.game_number] = t.game # finished games stay intact once replaced + if isinstance(ev, MatchRoom) and ev.state == "MatchGameRoomStateType_MatchCompleted": + saved = self._finish(t, ev) + self.reset() + return saved + return False + + def _finish(self, t: MatchTracker, ev: MatchRoom) -> bool: + if not t.match_id or not self.games or self.store.has_match(t.match_id): + return False + results = [r for r in (ev.final_result or {}).get("resultList") or [] if r.get("scope") == "MatchScope_Game"] + summaries = [] + for n, g in sorted(self.games.items()): + if n - 1 < len(results) and t.my_seat is not None: + result = "Won" if results[n - 1].get("winningTeamId") == t.my_seat else "Lost" + elif n - 1 < len(t.game_results): + result = t.game_results[n - 1] + else: + result = "" + summaries.append(summarize(t, g, result)) + self.store.save_match(t.match_id, summaries, self.buffer or []) + return True + + +def replay_game(events: list[Event], game_number: int, cards) -> MatchTracker: + """Rebuild a tracker as it was at the end of the given game of a stored match.""" + t = MatchTracker(card_lookup=cards) + for ev in events: + if isinstance(ev, GreMessages) and any( + (m.get("gameStateMessage") or {}).get("type") == "GameStateType_Full" + and ((m.get("gameStateMessage") or {}).get("gameInfo") or {}).get("gameNumber", 0) > game_number + for m in ev.messages + ): + break # the next game starts here + t.handle(ev) + return t + + +def import_log(path: Path, store: HistoryStore, cards) -> int: + """Record every completed match in a log file. Returns how many were new.""" + parser, tracker, recorder = LogParser(), MatchTracker(card_lookup=cards), HistoryRecorder(store) + saved = 0 + with open(path, encoding="utf-8", errors="replace") as f: + for line in f: + for ev in parser.feed(line): + tracker.handle(ev) + saved += recorder.observe(ev, tracker) + return saved diff --git a/src/mtga_assistant/images.py b/src/mtga_assistant/images.py index ee49868..b829021 100644 --- a/src/mtga_assistant/images.py +++ b/src/mtga_assistant/images.py @@ -47,7 +47,9 @@ class ImageCache(QObject): self.evict() def path_for(self, grp_id: int) -> Path: - return self.dir / f"{grp_id}.jpg" + # Tokens get their own names: early versions fetched wrong images for them. + prefix = "t" if self.cards.get(grp_id).is_token else "" + return self.dir / f"{prefix}{grp_id}.jpg" def pixmap(self, grp_id: int) -> QPixmap | None: """Return the image if cached; otherwise start a download and return None.""" @@ -89,8 +91,16 @@ class ImageCache(QObject): def _candidate_urls(self, grp_id: int) -> list[str]: q = "format=image&version=normal" - urls = [f"{SCRYFALL}/cards/arena/{grp_id}?{q}"] info = self.cards.get(grp_id) + if info.is_token: + # MTGA token ids/numbers don't line up with Scryfall's cards; tokens live in "t". + name = urllib.parse.quote(info.name) + urls = [] + if info.set_code: + urls.append(f"{SCRYFALL}/cards/named?exact={name}&set=t{urllib.parse.quote(info.set_code.lower())}&{q}") + urls.append(f"{SCRYFALL}/cards/search?q={urllib.parse.quote(f'!"{info.name}" is:token')}&format=image&version=normal") + return urls + urls = [f"{SCRYFALL}/cards/arena/{grp_id}?{q}"] if info.set_code and info.collector_number: set_code = urllib.parse.quote(info.set_code.lower()) cn = urllib.parse.quote(info.collector_number) @@ -153,8 +163,8 @@ class ImageCache(QObject): except OSError: continue total -= size - if p.stem.isdigit(): - self._pixmaps.pop(int(p.stem), None) + if p.stem.lstrip("t").isdigit(): + self._pixmaps.pop(int(p.stem.lstrip("t")), None) if total <= self.max_bytes * 0.9: break self.size_changed.emit(total) diff --git a/src/mtga_assistant/logparse.py b/src/mtga_assistant/logparse.py index c882d21..77d3740 100644 --- a/src/mtga_assistant/logparse.py +++ b/src/mtga_assistant/logparse.py @@ -24,6 +24,7 @@ MAX_PENDING_LINES = 5000 @dataclass class GreMessages: messages: list[dict[str, Any]] + timestamp_ms: int | None = None # server send time (epoch ms), anchors live timers @dataclass @@ -105,7 +106,11 @@ def _classify(obj: Any) -> list[Event]: return [] if gre := obj.get("greToClientEvent"): msgs = gre.get("greToClientMessages") or [] - return [GreMessages(msgs)] if msgs else [] + try: + ts = int(obj["timestamp"]) + except (KeyError, TypeError, ValueError): + ts = None + return [GreMessages(msgs, ts)] if msgs else [] if room := obj.get("matchGameRoomStateChangedEvent"): info = room.get("gameRoomInfo") or {} cfg = info.get("gameRoomConfig") or {} diff --git a/src/mtga_assistant/paths.py b/src/mtga_assistant/paths.py index 314fed7..db28b12 100644 --- a/src/mtga_assistant/paths.py +++ b/src/mtga_assistant/paths.py @@ -15,6 +15,7 @@ LOG_SUBPATH = Path( CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant" CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant" +DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "mtga-assistant" CONFIG_FILE = CONFIG_DIR / "config.toml" STEAM_ROOTS = [ @@ -30,6 +31,7 @@ class Config: log_path: Path | None = None mtga_dir: Path | None = None cache_max_mb: int = 500 + history_max_mb: int = 300 def load_config() -> Config: @@ -44,6 +46,8 @@ def load_config() -> Config: cfg.mtga_dir = Path(p).expanduser() if (mb := data.get("cache_max_mb")) is not None: cfg.cache_max_mb = int(mb) + if (mb := data.get("history_max_mb")) is not None: + cfg.history_max_mb = int(mb) return cfg diff --git a/src/mtga_assistant/turnstats.py b/src/mtga_assistant/turnstats.py new file mode 100644 index 0000000..43cc464 --- /dev/null +++ b/src/mtga_assistant/turnstats.py @@ -0,0 +1,162 @@ +"""Per-turn, per-player event counts reconstructed from GRE annotations. Pure Python.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, Protocol + +# Counter types from the card DB's CounterType enum. +COUNTER_POISON = 3 +COUNTER_ENERGY = 35 + +STATS: list[tuple[str, str]] = [ + ("drawn", "Cards drawn"), + ("cast", "Spells cast"), + ("lands", "Lands played"), + ("creatures_died", "Creatures died"), + ("permanents_died", "Permanents died"), + ("life_gained", "Life gained"), + ("life_lost", "Life lost"), + ("damage_taken", "Damage taken"), + ("discarded", "Cards discarded"), + ("milled", "Cards milled"), + ("exiled", "Cards exiled"), + ("countered", "Spells countered"), + ("tokens_created", "Tokens created"), + ("poison_gained", "Poison counters gained"), + ("energy_gained", "Energy gained"), + ("energy_spent", "Energy spent"), +] +STAT_LABELS = dict(STATS) + +PERMANENT_TYPES = { + "CardType_Artifact", "CardType_Creature", "CardType_Enchantment", "CardType_Land", + "CardType_Planeswalker", "CardType_Battle", +} +CATEGORY_STATS = { + "Draw": "drawn", + "CastSpell": "cast", + "PlayLand": "lands", + "Discard": "discarded", + "Mill": "milled", + "Countered": "countered", +} + + +class ZoneLike(Protocol): + type: str + owner: int | None + + +def details(annotation: dict[str, Any]) -> dict[str, list[Any]]: + out: dict[str, list[Any]] = {} + for d in annotation.get("details") or []: + out[d.get("key", "")] = d.get("valueInt32") or d.get("valueString") or d.get("valueUint32") or [] + return out + + +@dataclass +class TurnLog: + turns: dict[int, dict[str, Counter[int]]] = field(default_factory=dict) + active: dict[int, int] = field(default_factory=dict) # turn number -> active seat + land_allowed: dict[int, int] = field(default_factory=dict) # turn number -> land plays allowed + + def land_drop(self, turn: int) -> tuple[int, int] | None: + """(lands played, land plays allowed) by the active player of that turn.""" + seat = self.active.get(turn) + if seat is None: + return None + return self.turn(turn).get("lands", Counter()).get(seat, 0), self.land_allowed.get(turn, 1) + + def land_drops_total(self, seat: int) -> tuple[int, int]: + """(lands played, land plays allowed) summed over the seat's turns.""" + used = allowed = 0 + for turn, active in self.active.items(): + if active == seat: + u, a = self.land_drop(turn) or (0, 1) + used, allowed = used + u, allowed + a + return used, allowed + + def start_turn(self, turn: int, active_seat: int) -> None: + if turn > 0: + self.active.setdefault(turn, active_seat) + self.turns.setdefault(turn, {}) + + def add(self, turn: int, stat: str, seat: int | None, n: int = 1) -> None: + if seat is None or n == 0: + return + self.turns.setdefault(turn, {}).setdefault(stat, Counter())[seat] += n + + def turn(self, n: int) -> dict[str, Counter[int]]: + return self.turns.get(n, {}) + + def turn_numbers(self) -> list[int]: + return sorted(self.turns) + + def game_totals(self) -> dict[str, Counter[int]]: + out: dict[str, Counter[int]] = {} + for stats in self.turns.values(): + for k, c in stats.items(): + out.setdefault(k, Counter()).update(c) + return out + + def record(self, turn: int, annotations: list[dict[str, Any]], zones_before: dict[int, ZoneLike], + zones_now: dict[int, ZoneLike], objects: dict[int, dict[str, Any]], seats: set[int]) -> None: + """Attribute one message's annotations. Objects/zones must already reflect the message.""" + + def zone(zid: int) -> ZoneLike | None: + return zones_before.get(zid) or zones_now.get(zid) + + for ann in annotations: + types = ann.get("type") or [] + affected = ann.get("affectedIds") or [] + d = details(ann) + + if "AnnotationType_ZoneTransfer" in types and affected: + obj = objects.get(affected[0]) or {} + src = zone(d["zone_src"][0]) if d.get("zone_src") else None + dest = zone(d["zone_dest"][0]) if d.get("zone_dest") else None + category = (d.get("category") or [""])[0] + owner = obj.get("ownerSeatId") or (src.owner if src else None) or (dest.owner if dest else None) + controller = obj.get("controllerSeatId") or owner + if stat := CATEGORY_STATS.get(category): + self.add(turn, stat, controller if stat in ("cast", "lands") else owner) + if dest and dest.type == "ZoneType_Exile": + self.add(turn, "exiled", owner) + if src and dest and src.type == "ZoneType_Battlefield" and dest.type == "ZoneType_Graveyard": + card_types = set(obj.get("cardTypes") or []) + if "CardType_Creature" in card_types: + self.add(turn, "creatures_died", owner) + if card_types & PERMANENT_TYPES or obj.get("type") == "GameObjectType_Token": + self.add(turn, "permanents_died", owner) + + elif "AnnotationType_ModifiedLife" in types and d.get("life"): + delta = d["life"][0] + for seat in affected: + if seat in seats: + self.add(turn, "life_gained" if delta > 0 else "life_lost", seat, abs(delta)) + + elif "AnnotationType_DamageDealt" in types and d.get("damage"): + for seat in affected: + if seat in seats: + self.add(turn, "damage_taken", seat, d["damage"][0]) + + elif "AnnotationType_TokenCreated" in types: + for iid in affected: + obj = objects.get(iid) or {} + self.add(turn, "tokens_created", obj.get("controllerSeatId") or obj.get("ownerSeatId")) + + elif ("AnnotationType_CounterAdded" in types or "AnnotationType_CounterRemoved" in types) \ + and d.get("counter_type"): + ctype = d["counter_type"][0] + amount = (d.get("transaction_amount") or [1])[0] + added = "AnnotationType_CounterAdded" in types + stat = { + (COUNTER_POISON, True): "poison_gained", + (COUNTER_ENERGY, True): "energy_gained", + (COUNTER_ENERGY, False): "energy_spent", + }.get((ctype, added)) + for seat in affected: + if seat in seats and stat: + self.add(turn, stat, seat, amount) diff --git a/src/mtga_assistant/ui/arrange.py b/src/mtga_assistant/ui/arrange.py new file mode 100644 index 0000000..49bb488 --- /dev/null +++ b/src/mtga_assistant/ui/arrange.py @@ -0,0 +1,106 @@ +"""Dialog to reorder (drag or ↑/↓) and show/hide the items of one or more Toggles groups.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QAbstractItemView, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, +) + +from .toggles import Toggles + +KEY_ROLE = Qt.ItemDataRole.UserRole + 1 + + +class ArrangeList(QListWidget): + def __init__(self, toggles: Toggles, parent=None): + super().__init__(parent) + self.toggles = toggles + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.setMinimumWidth(280) + self.load(toggles.ordered_items(), {k: toggles.on(k) for k, _ in toggles.items}) + + def load(self, items: list[tuple[str, str]], visible: dict[str, bool]) -> None: + self.clear() + for key, label in items: + it = QListWidgetItem(f"⠿ {label}") + it.setData(KEY_ROLE, key) + it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsDragEnabled) + it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsDropEnabled) # drop between items, not onto them + it.setCheckState(Qt.CheckState.Checked if visible.get(key, True) else Qt.CheckState.Unchecked) + self.addItem(it) + + def move_current(self, delta: int) -> None: + row = self.currentRow() + target = row + delta + if row < 0 or not 0 <= target < self.count(): + return + item = self.takeItem(row) + self.insertItem(target, item) + self.setCurrentRow(target) + + def result(self) -> tuple[list[str], dict[str, bool]]: + order, visible = [], {} + for i in range(self.count()): + it = self.item(i) + key = it.data(KEY_ROLE) + order.append(key) + visible[key] = it.checkState() == Qt.CheckState.Checked + return order, visible + + +class ArrangeDialog(QDialog): + def __init__(self, groups: list[Toggles], title: str, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.lists: list[ArrangeList] = [] + + columns = QHBoxLayout() + for tg in groups: + col = QVBoxLayout() + heading = QLabel(tg.title or title) + heading.setObjectName("cardTitle") + col.addWidget(heading) + lst = ArrangeList(tg) + self.lists.append(lst) + col.addWidget(lst, 1) + buttons = QHBoxLayout() + for text, delta in (("↑", -1), ("↓", 1)): + b = QPushButton(text) + b.setToolTip("Move the selected item " + ("up" if delta < 0 else "down")) + b.clicked.connect(lambda _=False, l=lst, d=delta: l.move_current(d)) + buttons.addWidget(b) + reset = QPushButton("Reset") + reset.setToolTip("Default order, everything shown") + reset.clicked.connect(lambda _=False, l=lst, t=tg: l.load(t.items, {k: True for k, _ in t.items})) + buttons.addStretch(1) + buttons.addWidget(reset) + col.addLayout(buttons) + columns.addLayout(col) + + hint = QLabel("Drag items (or select and use ↑/↓) to reorder. Untick to hide.") + hint.setObjectName("muted") + box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + box.accepted.connect(self.accept) + box.rejected.connect(self.reject) + + lay = QVBoxLayout(self) + lay.addWidget(hint) + lay.addLayout(columns, 1) + lay.addWidget(box) + self.resize(360 * len(groups), 560) + + def accept(self) -> None: + for lst in self.lists: + lst.toggles.apply(*lst.result()) + super().accept() diff --git a/src/mtga_assistant/ui/card_list.py b/src/mtga_assistant/ui/card_list.py index 56c24c6..3925a0d 100644 --- a/src/mtga_assistant/ui/card_list.py +++ b/src/mtga_assistant/ui/card_list.py @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( ) from ..carddb import CardInfo +from . import theme GRP_ROLE = Qt.ItemDataRole.UserRole + 1 MANA_ROLE = Qt.ItemDataRole.UserRole + 2 @@ -55,7 +56,7 @@ def sort_key(row: Row) -> tuple: class ManaDelegate(QStyledItemDelegate): - PIP = 16 + PIP = 18 def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: symbols = index.data(MANA_ROLE) @@ -85,7 +86,7 @@ class ManaDelegate(QStyledItemDelegate): phyrexian = "P" in parts parts = [p for p in parts if p != "P"] or ["C"] fills = [MANA_COLORS.get(p, GENERIC_COLOR) for p in parts] - painter.setPen(QPen(QColor("#555"), 0.8)) + painter.setPen(QPen(QColor("#0d0f11"), 1.0)) if len(fills) >= 2: # hybrid: two half circles painter.setBrush(QBrush(QColor(fills[0]))) painter.drawPie(rect, 90 * 16, 180 * 16) @@ -156,11 +157,13 @@ class CardList(QTreeWidget): self.clear() bold = QFont(self.font()) bold.setBold(True) - dim_brush = QBrush(self.palette().color(self.palette().ColorRole.PlaceholderText)) + dim_brush = QBrush(QColor(theme.MUTED)) + header_brush = QBrush(QColor(theme.MINT_TEXT)) for sec in sections: top = QTreeWidgetItem([sec.title, "", "", ""]) top.setFirstColumnSpanned(True) top.setFont(0, bold) + top.setForeground(0, header_brush) top.setData(0, GRP_ROLE + 10, sec.key) top.setFlags(Qt.ItemFlag.ItemIsEnabled) self.addTopLevelItem(top) diff --git a/src/mtga_assistant/ui/deck_view.py b/src/mtga_assistant/ui/deck_view.py new file mode 100644 index 0000000..3b479e8 --- /dev/null +++ b/src/mtga_assistant/ui/deck_view.py @@ -0,0 +1,170 @@ +"""Deck view: my deck | opponent | card preview.""" + +from __future__ import annotations + +from collections import Counter + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFrame, QLabel, QSplitter, QVBoxLayout, QWidget + +from ..carddb import CardDB +from ..gamestate import GameState, MatchTracker +from ..images import ImageCache +from .card_list import CardList, Row, Section +from .preview import CardPreview +from .toggles import Toggles + +MY_SECTIONS = [ + ("commander", "Commander"), + ("library", "Library"), + ("hand", "Hand"), + ("stack", "Stack"), + ("battlefield", "Battlefield"), + ("graveyard", "Graveyard"), + ("exile", "Exile"), + ("sideboard", "Sideboard"), +] +OPP_SECTIONS = [ + ("commander", "Commander"), + ("battlefield", "Battlefield"), + ("stack", "Stack"), + ("hand", "Hand (known cards)"), + ("graveyard", "Graveyard"), + ("exile", "Exile"), + ("library", "Library (known cards)"), + ("seen", "Seen this match"), +] +# Sections shown even when empty, because their titles carry useful counts. +MY_ALWAYS = {"library", "hand"} +OPP_ALWAYS = {"battlefield", "hand", "seen"} + + +def arrange(sections: list[Section], toggles: Toggles, always: set[str]) -> list[Section]: + """Order sections as the user arranged them, dropping hidden and (most) empty ones.""" + by_key = {s.key: s for s in sections} + return [by_key[k] for k in toggles.order() + if k in by_key and toggles.on(k) and (by_key[k].rows or k in always)] + + +def card_frame(title: str, body: QWidget) -> QFrame: + frame = QFrame() + frame.setObjectName("card") + lay = QVBoxLayout(frame) + lay.setContentsMargins(10, 8, 10, 10) + lay.setSpacing(6) + label = QLabel(title) + label.setObjectName("cardTitle") + lay.addWidget(label) + lay.addWidget(body, 1) + return frame + + +class DeckView(QWidget): + def __init__(self, cards: CardDB, images: ImageCache, my_sections: Toggles, opp_sections: Toggles, + parent=None): + super().__init__(parent) + self.cards = cards + self.my_sections = my_sections + self.opp_sections = opp_sections + self.mine = CardList() + self.opp = CardList() + self.preview = CardPreview(cards, images) + self.mine.hovered.connect(self.preview.show_card) + self.opp.hovered.connect(self.preview.show_card) + + split = QSplitter(Qt.Orientation.Horizontal) + split.addWidget(card_frame("My deck", self.mine)) + split.addWidget(card_frame("Opponent", self.opp)) + split.addWidget(card_frame("Preview", self.preview)) + split.setStretchFactor(0, 3) + split.setStretchFactor(1, 3) + split.setStretchFactor(2, 2) + split.setChildrenCollapsible(False) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(split) + + 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) + for grp, n in counts.items() + if n > 0 + ] + + def render(self, t: MatchTracker) -> None: + g = t.game + if g is None: + self._render_idle(t) + else: + self._render_mine(t, g) + self._render_opp(t, g) + + def _commander_section(self, g: GameState, seat: int) -> list[Section]: + status = g.commander_status(seat) + if not status: + return [] + tax = g.commander_tax(seat) + rows = [Row(self.cards.get(grp), 1, where or "hidden") for grp, where in status] + title = "Commander" + ("s" if len(rows) > 1 else "") + (f" (tax +{tax})" if tax else "") + return [Section("commander", title, rows)] + + def _render_idle(self, t: MatchTracker) -> None: + sections = [] + if d := t.submitted_deck: + sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})", + self._rows(d.main))) + if d.sideboard: + sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})", + self._rows(d.sideboard), collapsed_by_default=True)) + self.mine.set_sections(sections) + self.opp.set_sections([]) + + def _render_mine(self, t: MatchTracker, g: GameState) -> None: + me = g.my_seat + lib_size = g.zone_size("ZoneType_Library", me) + remaining = t.my_library_remaining() + denom = lib_size or sum(remaining.values()) or 1 + + def pct(_grp: int, n: int) -> str: + return f"{100 * n / denom:.0f}%" + + # Every card in the deck; ones with no copies left stay listed, greyed out. + lib_rows = [ + Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n) + for grp in g.deck + for n in [remaining.get(grp, 0)] + ] + + sections = 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("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})", + self._rows(g.cards_in("ZoneType_Graveyard", owner=me))), + Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))), + Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard), + collapsed_by_default=True), + ] + self.mine.set_sections(arrange(sections, self.my_sections, MY_ALWAYS)) + + def _render_opp(self, t: MatchTracker, g: GameState) -> None: + opp = g.opp_seat + hand_size = g.zone_size("ZoneType_Hand", opp) + lib_size = g.zone_size("ZoneType_Library", opp) + known_hand = g.opp_known_in("ZoneType_Hand") + known_lib = g.opp_known_in("ZoneType_Library") + seen = t.opp_seen_match + sections = self._commander_section(g, opp) + [ + Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))), + Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))), + Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)), + Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})", + self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))), + Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))), + Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)), + Section("seen", f"Seen this match ({sum(seen.values())} cards)", + [r for r in self._rows(seen) if not r.info.is_token]), + ] + self.opp.set_sections(arrange(sections, self.opp_sections, OPP_ALWAYS)) diff --git a/src/mtga_assistant/ui/history_view.py b/src/mtga_assistant/ui/history_view.py new file mode 100644 index 0000000..6bd63cd --- /dev/null +++ b/src/mtga_assistant/ui/history_view.py @@ -0,0 +1,147 @@ +"""History view: games played; click one to open it in the deck/match/turn views.""" + +from __future__ import annotations + +from datetime import datetime + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from ..history import GameSummary +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 +KEY_ROLE = Qt.ItemDataRole.UserRole + 20 + + +def fmt_when(ms: int | None) -> str: + if not ms: + return "?" + dt = datetime.fromtimestamp(ms / 1000) + today = datetime.now().date() + if dt.date() == today: + return dt.strftime("Today %H:%M") + return dt.strftime("%b %d %H:%M") + + +def _fmt_length(g: GameSummary) -> str: + if not g.started_ms or not g.ended_ms: + return "" + sec = max(0, (g.ended_ms - g.started_ms) // 1000) + return f"{sec // 60}:{sec % 60:02}" + + +class HistoryView(QWidget): + open_game = Signal(str, int) # match_id, game_number + + def __init__(self, parent=None): + super().__init__(parent) + self.table = QTableWidget(0, len(COLUMNS)) + self.table.setHorizontalHeaderLabels(COLUMNS) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.table.setShowGrid(False) + self.table.setAlternatingRowColors(True) + self.table.setMouseTracking(True) + self.table.verticalHeader().hide() + self.table.verticalHeader().setDefaultSectionSize(36) + hdr = self.table.horizontalHeader() + hdr.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + for col in (COL_MINE, COL_THEIRS): + self.table.setItemDelegateForColumn(col, 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.setObjectName("muted") + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + + top = QHBoxLayout() + top.addWidget(self.summary, 1) + hint = QLabel("Click a game to open it") + hint.setObjectName("muted") + top.addWidget(hint) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addLayout(top) + lay.addWidget(card_frame("Games played", self.table), 1) + lay.addWidget(self.empty) + + def _clicked(self, row: int, _col: int) -> 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) + + 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.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)] + for c, v in enumerate(values): + item = QTableWidgetItem(v) + item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + 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)) + + play = self.table.item(r, COL_PLAY) + if g.on_play == "me": + play.setText("▶ You") + play.setForeground(QColor(theme.MINT_TEXT)) + play.setToolTip("You were on the play (went first)") + elif g.on_play == "opp": + play.setText("▶ Them") + play.setForeground(QColor(theme.MUTED)) + play.setToolTip("Your opponent was on the play; you were on the draw") + else: + play.setText("–") + play.setToolTip("The game ended before the first turn") + + self.table.item(r, COL_MINE).setData(MANA_ROLE, list(g.my_colors) or ["C"]) + self.table.item(r, COL_THEIRS).setData(MANA_ROLE, list(g.opp_colors) or ["C"]) + self.table.item(r, COL_THEIRS).setToolTip("Colours of the opponent's cards that were seen") + + res = self.table.item(r, COL_RESULT) + if g.result == "Won": + res.setForeground(QColor(theme.MINT_TEXT)) + elif g.result == "Lost": + res.setForeground(QColor(theme.DANGER)) diff --git a/src/mtga_assistant/ui/main_window.py b/src/mtga_assistant/ui/main_window.py index 955def5..5d7adab 100644 --- a/src/mtga_assistant/ui/main_window.py +++ b/src/mtga_assistant/ui/main_window.py @@ -1,130 +1,264 @@ -"""Main window: my deck | opponent | card preview.""" +"""Main window: a top bar switching between the Deck, Match and Turn views.""" from __future__ import annotations -from collections import Counter +import time from pathlib import Path -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWidgets import ( + QButtonGroup, QFileDialog, + QFrame, QHBoxLayout, QLabel, QMainWindow, + QMenu, QMessageBox, - QSplitter, + QPushButton, + QStackedWidget, + QToolButton, QVBoxLayout, QWidget, ) from ..carddb import CardDB -from ..gamestate import GameState, MatchTracker +from ..gamestate import MatchTracker +from ..history import HistoryRecorder, HistoryStore, import_log, replay_game from ..images import ImageCache from ..logtail import LogFollower -from .card_list import CardList, Row, Section -from .preview import CardPreview +from . import theme +from .arrange import ArrangeDialog +from .deck_view import MY_SECTIONS, OPP_SECTIONS, DeckView +from .history_view import HistoryView, fmt_when +from .match_view import MATCH_TOGGLES, MatchView +from .toggles import StayOpenMenu, Toggles +from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView REFRESH_MS = 150 +DECK_TOGGLES = [("preview", "Card preview pane")] +VIEWS = ["Deck", "Match", "Turn", "History"] +HISTORY = 3 -def _panel(title: str, body: QWidget) -> QWidget: - w = QWidget() - lay = QVBoxLayout(w) - lay.setContentsMargins(4, 4, 4, 4) - label = QLabel(f"{title}") - lay.addWidget(label) - lay.addWidget(body, 1) - return w +class HistoryImport(QThread): + """Records completed matches from an older log (Player-prev.log) in the background.""" + + done = Signal(int) + + def __init__(self, path: Path, store: HistoryStore, cards: CardDB): + super().__init__() + self.path, self.store, self.cards = path, store, cards + + def run(self) -> None: + try: + st = self.path.stat() + except OSError: + return + stamp = f"{st.st_size}:{st.st_mtime_ns}" + key = f"imported:{self.path}" + if self.store.meta(key) == stamp: + return + # Own card DB connection: this runs off the GUI thread. + saved = import_log(self.path, self.store, CardDB(self.cards.path)) + self.store.set_meta(key, stamp) + self.done.emit(saved) class MainWindow(QMainWindow): - def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None, + def __init__(self, cards: CardDB, images: ImageCache, history: HistoryStore, log_path: Path | None, replay_rate: float | None = None): super().__init__() self.setWindowTitle("MTGA Assistant") self.cards = cards self.images = images + self.history = history + self.recorder = HistoryRecorder(history) self.tracker = MatchTracker(card_lookup=cards) + # A past game opened from the History view: (tracker, summary text). None = live. + self.past: tuple[MatchTracker, str] | None = None + self._importers: list[HistoryImport] = [] self.follower: LogFollower | None = None - self._rendered_version = -1 + self.replaying = False + self._rendered: dict[int, int] = {} # view index -> tracker version last rendered + self._settings = QSettings("mtga-assistant", "mtga-assistant") - # Header + self.toggles = [ + Toggles("deck", DECK_TOGGLES, parent=self), + Toggles("match", MATCH_TOGGLES, parent=self), + Toggles("turn", TURN_TOGGLES, TURN_DEFAULTS_OFF, parent=self), + ] + self.my_sections = Toggles("deck_mine", MY_SECTIONS, title="My deck", parent=self) + self.opp_sections = Toggles("deck_opp", OPP_SECTIONS, title="Opponent", parent=self) + self.toggles[2].title = "Turn stats" + for tg in (*self.toggles, self.my_sections, self.opp_sections): + tg.changed.connect(self._force_refresh) + + # Views. One DeckView is shared by the Deck page and the bottom half of the Match page. + self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections) + self.deck_page = QWidget() + self.deck_page_layout = QVBoxLayout(self.deck_page) + self.deck_page_layout.setContentsMargins(0, 0, 0, 0) + self.match_view = MatchView(cards, self.toggles[1]) + self.match_view.hovered.connect(self.deck_view.preview.show_card) + self.turn_view = TurnView(self.toggles[2]) + self.history_view = HistoryView() + self.history_view.open_game.connect(self.open_past_game) + self.stack = QStackedWidget() + for page in (self.deck_page, self.match_view, self.turn_view, self.history_view): + self.stack.addWidget(page) + + # Top bar + self.nav = QButtonGroup(self) + self.nav.setExclusive(True) + bar = QHBoxLayout() + bar.setSpacing(8) + for i, name in enumerate(VIEWS): + btn = QPushButton(name) + btn.setObjectName("navButton") + btn.setCheckable(True) + btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}")) + btn.setToolTip(f"{name} view (Ctrl+{i + 1})") + self.nav.addButton(btn, i) + bar.addWidget(btn) + self.nav.idClicked.connect(self.show_view) + bar.addSpacing(16) self.header = QLabel("Waiting for a match…") self.header.setTextFormat(Qt.TextFormat.RichText) + bar.addWidget(self.header, 1) + self.customize = QToolButton() + self.customize.setText("⚙ Customize") + self.customize.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.customize_menu = StayOpenMenu(self) + self.customize_menu.aboutToShow.connect(self._fill_customize) + self.customize.setMenu(self.customize_menu) + bar.addWidget(self.customize) + bar.addWidget(self._app_menu_button()) + self.warning = QLabel() - self.warning.setStyleSheet("color: #d08770;") + self.warning.setObjectName("banner") self.warning.setWordWrap(True) self.warning.hide() - # Panels - self.mine = CardList() - self.opp = CardList() - self.preview = CardPreview(cards, images) - self.mine.hovered.connect(self.preview.show_card) - self.opp.hovered.connect(self.preview.show_card) - - split = QSplitter(Qt.Orientation.Horizontal) - split.addWidget(_panel("My deck", self.mine)) - split.addWidget(_panel("Opponent", self.opp)) - split.addWidget(self.preview) - split.setStretchFactor(0, 3) - split.setStretchFactor(1, 3) - split.setStretchFactor(2, 2) + # Shown while looking at a past game. + self.past_bar = QFrame() + self.past_bar.setObjectName("pastBar") + past_lay = QHBoxLayout(self.past_bar) + past_lay.setContentsMargins(12, 6, 8, 6) + self.past_label = QLabel() + self.past_label.setTextFormat(Qt.TextFormat.RichText) + back = QPushButton("Back to live") + back.clicked.connect(self.close_past_game) + past_lay.addWidget(self.past_label, 1) + past_lay.addWidget(back) + self.past_bar.hide() central = QWidget() + central.setObjectName("page") lay = QVBoxLayout(central) - top = QHBoxLayout() - top.addWidget(self.header, 1) - lay.addLayout(top) + lay.setContentsMargins(12, 10, 12, 6) + lay.setSpacing(10) + lay.addLayout(bar) lay.addWidget(self.warning) - lay.addWidget(split, 1) + lay.addWidget(self.past_bar) + lay.addWidget(self.stack, 1) self.setCentralWidget(central) # Status bar self.status_log = QLabel() self.status_cache = QLabel() + self.status_gap = QLabel() + self.status_gap.setStyleSheet(f"color: {theme.DANGER};") + self.status_gap.hide() self.statusBar().addWidget(self.status_log, 1) + self.statusBar().addPermanentWidget(self.status_gap) self.statusBar().addPermanentWidget(self.status_cache) images.size_changed.connect(self._on_cache_size) self._on_cache_size(images.disk_usage()) - if not cards.available: - self.warning.setText("MTGA card database not found: card names are unavailable. " - "Set mtga_dir in ~/.config/mtga-assistant/config.toml.") - self.warning.show() - - self._build_menu() self.refresh_timer = QTimer(self) self.refresh_timer.setInterval(REFRESH_MS) self.refresh_timer.timeout.connect(self.refresh) self.refresh_timer.start() + self.clock_timer = QTimer(self) + self.clock_timer.setInterval(1000) + self.clock_timer.timeout.connect(self._tick) + self.clock_timer.start() - self.resize(1280, 800) + self.resize(1400, 880) + self.history_view.set_games(history.games()) + self.show_view(int(self._settings.value("view", 0, type=int))) if log_path is not None: self.start_log(log_path, replay_rate) else: - self.status_log.setText("Player.log not found: use File → Open log…") + self.status_log.setText("Player.log not found: use ☰ → Open log…") - # -- menu ---------------------------------------------------------------- + # -- top bar ------------------------------------------------------------- - def _build_menu(self) -> None: - file_menu = self.menuBar().addMenu("&File") - open_act = QAction("&Open log…", self) + def _app_menu_button(self) -> QToolButton: + btn = QToolButton() + btn.setText("☰") + btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + menu = QMenu(self) + open_act = menu.addAction("Open log…") open_act.triggered.connect(lambda: self._choose_log(None)) - replay_act = QAction("&Replay log…", self) + replay_act = menu.addAction("Replay log…") replay_act.triggered.connect(lambda: self._choose_log(40.0)) - quit_act = QAction("&Quit", self) + menu.addSeparator() + clear_act = menu.addAction("Clear image cache") + clear_act.triggered.connect(self._clear_cache) + menu.addSeparator() + quit_act = QAction("Quit", self) quit_act.setShortcut(QKeySequence.StandardKey.Quit) quit_act.triggered.connect(self.close) - file_menu.addActions([open_act, replay_act]) - file_menu.addSeparator() - file_menu.addAction(quit_act) + self.addAction(quit_act) # keep the shortcut active while the menu is closed + menu.addAction(quit_act) + btn.setMenu(menu) + return btn - cache_menu = self.menuBar().addMenu("&Cache") - clear_act = QAction("&Clear image cache", self) - clear_act.triggered.connect(self._clear_cache) - cache_menu.addAction(clear_act) + def _arrangeable(self) -> list[Toggles]: + """Toggle groups whose order can be arranged in the current view (deck view in match view too).""" + index = self.stack.currentIndex() + if index in (0, 1): + return [self.my_sections, self.opp_sections] + return [self.toggles[2]] + + def _fill_customize(self) -> None: + menu = self.customize_menu + menu.clear() + index = self.stack.currentIndex() + if index == 2: + self.toggles[2].fill_menu(menu) + else: + self.toggles[index].fill_menu(menu) + for tg in (self.my_sections, self.opp_sections): + sub = StayOpenMenu(f"{tg.title} sections", menu) + tg.fill_menu(sub) + menu.addMenu(sub) + menu.addSeparator() + arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…") + arrange.triggered.connect(self._open_arrange) + + def _open_arrange(self) -> None: + index = self.stack.currentIndex() + title = "Arrange turn stats" if index == 2 else "Arrange deck sections" + ArrangeDialog(self._arrangeable(), title, self).exec() + + def show_view(self, index: int) -> None: + index = max(0, min(len(VIEWS) - 1, index)) + self.customize.setEnabled(index != HISTORY) + # Move the shared deck view to wherever it is needed. + if index == 1: + self.match_view.deck_slot_layout.addWidget(self.deck_view) + else: + self.deck_page_layout.addWidget(self.deck_view) + self.stack.setCurrentIndex(index) + self.nav.button(index).setChecked(True) + self._settings.setValue("view", index) + self._force_refresh() + + # -- menu actions ---------------------------------------------------------- def _choose_log(self, replay_rate: float | None) -> None: start = str(self.follower.path.parent) if self.follower else str(Path.home()) @@ -138,7 +272,7 @@ class MainWindow(QMainWindow): self.images.clear() def _on_cache_size(self, size: int) -> None: - self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB") + self.status_cache.setText(f"Image cache {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB") # -- log ----------------------------------------------------------------- @@ -147,6 +281,7 @@ class MainWindow(QMainWindow): self.follower.stop() self.follower.wait(2000) self._reset_tracker() + self.replaying = replay_rate is not None self.follower = LogFollower(path, replay_rate) self.follower.events.connect(self._on_events) self.follower.status.connect(self.status_log.setText) @@ -155,13 +290,56 @@ class MainWindow(QMainWindow): def _reset_tracker(self) -> None: self.tracker = MatchTracker(card_lookup=self.cards) - self._rendered_version = -1 + self.recorder.reset() + self._force_refresh() def _on_events(self, events: list) -> None: + saved = False for ev in events: self.tracker.handle(ev) + saved |= self.recorder.observe(ev, self.tracker) + if saved: + self.history_view.set_games(self.history.games()) + + # -- history --------------------------------------------------------------- + + def import_history(self, path: Path) -> None: + worker = HistoryImport(path, self.history, self.cards) + worker.done.connect(lambda n: n and self.history_view.set_games(self.history.games())) + worker.finished.connect(lambda: self._importers.remove(worker)) + self._importers.append(worker) + worker.start() + + def open_past_game(self, match_id: str, game_number: int) -> None: + summary = next((g for g in self.history.games() + if g.match_id == match_id and g.game_number == game_number), None) + events = self.history.events(match_id) + if summary is None or not events: + return + tracker = replay_game(events, game_number, self.cards) + when = fmt_when(summary.started_ms) + result = {"Won": "won", "Lost": "lost"}.get(summary.result, "") + text = (f"Viewing a past game: vs {summary.opponent or '?'} · {when}" + + (f" · game {game_number} of 3" if summary.best_of == 3 else "") + + (f" · you {result}" if result else "")) + self.past = (tracker, text) + self.past_label.setText(text) + self.past_bar.show() + self.turn_view.selected = None + self.show_view(0) + + def close_past_game(self) -> None: + self.past = None + self.past_bar.hide() + self._force_refresh() + + @property + def shown_tracker(self) -> MatchTracker: + return self.past[0] if self.past else self.tracker def shutdown(self) -> None: + for worker in list(self._importers): + worker.wait(5000) if self.follower: self.follower.stop() self.follower.wait(2000) @@ -174,117 +352,69 @@ class MainWindow(QMainWindow): # -- rendering ------------------------------------------------------------- - def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]: - rows = [] - for grp, n in counts.items(): - if n <= 0: - continue - info = self.cards.get(grp) - rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim)) - return rows + def now_ms(self) -> int: + """Wall clock when following live; the newest log time when replaying or viewing the past.""" + t = self.shown_tracker + if (self.replaying or self.past) and t.last_ms: + return t.last_ms + return int(time.time() * 1000) + + def _force_refresh(self) -> None: + self._rendered.clear() + self.refresh() + + def _tick(self) -> None: + if self.stack.currentIndex() == 1: + self.match_view.tick(self.shown_tracker, self.now_ms()) def refresh(self) -> None: - t = self.tracker - if t.version == self._rendered_version: + t = self.shown_tracker + index = self.stack.currentIndex() + key = (id(t), t.version) + if self._rendered.get(index) == key: return - self._rendered_version = t.version + self._rendered[index] = key + self._render_header(t) + self.deck_view.preview.parentWidget().setVisible(self.toggles[0].on("preview")) + if index in (0, 1): + self.deck_view.render(t) + if index == 1: + self.match_view.render(t, self.now_ms()) + elif index == 2: + self.turn_view.render(t) + + def _render_header(self, t: MatchTracker) -> None: g = t.game - self._render_header(t, g) if g is None: - self._render_idle(t) - return - self._render_mine(t, g) - self._render_opp(t, g) - - def _render_header(self, t: MatchTracker, g: GameState | None) -> None: - if g is None: - self.header.setText("Waiting for a match…") - self.warning.setVisible(not self.cards.available) - return - opp = t.opponent_name or "opponent" - parts = [f"vs {opp}"] - if t.event_id: - parts.append(t.event_id.replace("_", " ")) - parts.append(f"Game {g.game_number}") - if g.turn: - whose = "your" if g.active_player == g.my_seat else "their" - parts.append(f"Turn {g.turn} ({whose})") - if g.life: - parts.append(f"Life: you {g.life.get(g.my_seat, '?')} · them {g.life.get(g.opp_seat, '?')}") - if t.game_results: - w = t.game_results.count("Won") - parts.append(f"Games {w}-{len(t.game_results) - w}") - if not t.in_match and t.last_result: - parts.append(f"Match {t.last_result.lower()}") - self.header.setText(" · ".join(parts)) - - lib_size = g.zone_size("ZoneType_Library", g.my_seat) - unaccounted = sum(t.my_library_remaining().values()) - lib_size if g.zones else 0 - if g.desynced or unaccounted: - self.warning.setText( - "Part of the game log was skipped by MTGA (oversized message), so some zones may be " - "out of date until they next change." - + (f" {unaccounted} card(s) of yours are unaccounted for." if unaccounted > 0 else "") - ) - self.warning.show() + self.header.setText("Waiting for a match…") else: - self.warning.setVisible(not self.cards.available) + parts = [f"vs {t.opponent_name or 'opponent'}"] + if t.event_id: + parts.append(t.event_id.replace("_", " ")) + parts.append(f"Game {g.game_number}") + if g.turn: + parts.append(f"Turn {g.turn}") + if not t.in_match and t.last_result: + parts.append(f"Match {t.last_result.lower()}") + self.header.setText(" · ".join(parts)) - def _render_idle(self, t: MatchTracker) -> None: - sections = [] - if d := t.submitted_deck: - sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})", - self._rows(d.main))) - if d.sideboard: - sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})", - self._rows(d.sideboard), collapsed_by_default=True)) - self.mine.set_sections(sections) - self.opp.set_sections([]) - - def _render_mine(self, t: MatchTracker, g: GameState) -> None: - me = g.my_seat - lib_size = g.zone_size("ZoneType_Library", me) - remaining = t.my_library_remaining() - denom = lib_size or sum(remaining.values()) or 1 - - def pct(_grp: int, n: int) -> str: - return f"{100 * n / denom:.0f}%" - - # Every card in the deck; ones with no copies left stay listed, greyed out. - lib_rows = [ - Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n) - for grp in g.deck - for n in [remaining.get(grp, 0)] - ] - - sections = [ - Section("library", f"Library ({lib_size})", lib_rows), - Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))), - Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))), - Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})", - self._rows(g.cards_in("ZoneType_Graveyard", owner=me))), - Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))), - Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard), - collapsed_by_default=True), - ] - self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")]) - - def _render_opp(self, t: MatchTracker, g: GameState) -> None: - opp = g.opp_seat - hand_size = g.zone_size("ZoneType_Hand", opp) - lib_size = g.zone_size("ZoneType_Library", opp) - known_hand = g.opp_known_in("ZoneType_Hand") - known_lib = g.opp_known_in("ZoneType_Library") - seen = t.opp_seen_match - sections = [ - Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))), - Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))), - Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)), - Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})", - self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))), - Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))), - Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)), - Section("seen", f"Seen this match ({sum(seen.values())} cards)", - [r for r in self._rows(seen) if not r.info.is_token]), - ] - self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")]) + messages = [] + if not self.cards.available: + messages.append("MTGA card database not found: card names are unavailable. " + "Set mtga_dir in ~/.config/mtga-assistant/config.toml.") + # MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the + # gap in; only flag it (quietly) while some of my cards are still not accounted for. + unaccounted = 0 + if g is not None and g.zones: + lib_size = g.zone_size("ZoneType_Library", g.my_seat) + unaccounted = sum(t.my_library_remaining().values()) - lib_size + if unaccounted > 0: + self.status_gap.setText(f"⚠ {unaccounted} of your cards not tracked") + self.status_gap.setToolTip( + "MTGA left part of this game out of its log (an oversized message), so " + f"{unaccounted} of your cards that left the library weren't seen. Library odds may be " + "slightly off until those cards show up again." + ) + self.status_gap.setVisible(unaccounted > 0) + self.warning.setText("\n".join(messages)) + self.warning.setVisible(bool(messages)) diff --git a/src/mtga_assistant/ui/match_view.py b/src/mtga_assistant/ui/match_view.py new file mode 100644 index 0000000..b55f3d2 --- /dev/null +++ b/src/mtga_assistant/ui/match_view.py @@ -0,0 +1,287 @@ +"""Match view: both players' status and game info on top, the deck view below.""" + +from __future__ import annotations + +import re + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QSplitter, + QVBoxLayout, + QWidget, +) + +from ..carddb import CardDB +from ..gamestate import GameState, MatchTracker +from . import theme +from .card_list import CardList, Row, Section +from .toggles import Toggles + +MATCH_TOGGLES = [ + ("game_info", "Game info (turn, phase, score)"), + ("play_draw", "On the play / on the draw"), + ("duration", "Game duration"), + ("life", "Life totals"), + ("counters", "Player counters (poison, energy…)"), + ("zones", "Hand / library / graveyard counts"), + ("land_drop", "Land drop (played/allowed)"), + ("timer", "Turn timer"), + ("timeouts", "Timeouts used"), + ("commander", "Commander"), + ("emblems", "Emblems"), +] + + +def pretty_enum(value: str) -> str: + """'Phase_Main1' -> 'Main 1', 'Step_BeginCombat' -> 'Begin combat'.""" + name = value.split("_", 1)[-1] + words = re.findall(r"[A-Z][a-z]*|\d+", name) + return " ".join([words[0]] + [w.lower() for w in words[1:]]) if words else "" + + +def fmt_seconds(sec: float) -> str: + sec = int(sec) + h, rem = divmod(sec, 3600) + m, s = divmod(rem, 60) + return f"{h}:{m:02}:{s:02}" if h else f"{m}:{s:02}" + + +def _label(text: str = "", name: str | None = None) -> QLabel: + lbl = QLabel(text) + if name: + lbl.setObjectName(name) + return lbl + + +class PlayerCard(QFrame): + hovered = Signal(int) + + def __init__(self, cards: CardDB, toggles: Toggles, parent=None): + super().__init__(parent) + self.setObjectName("card") + self.cards = cards + self.toggles = toggles + self.title = _label(name="cardTitle") + self.life = _label(name="bigNumber") + self.life_caption = _label("life", "muted") + self.counters = _label() + self.counters.setWordWrap(True) + self.zones = _label() + self.timer = _label() + self.timeouts = _label(name="muted") + self.land = _label() + self.commander = CardList() + self.commander.setMaximumHeight(90) + self.commander.hovered.connect(self.hovered) + self.emblems = CardList() + self.emblems.setMaximumHeight(120) + self.emblems.hovered.connect(self.hovered) + + life_row = QHBoxLayout() + life_row.addWidget(self.life) + life_row.addWidget(self.life_caption, 0, Qt.AlignmentFlag.AlignBottom) + life_row.addStretch(1) + timer_col = QVBoxLayout() + timer_col.addWidget(self.timer, 0, Qt.AlignmentFlag.AlignRight) + timer_col.addWidget(self.timeouts, 0, Qt.AlignmentFlag.AlignRight) + timer_col.addWidget(self.land, 0, Qt.AlignmentFlag.AlignRight) + life_row.addLayout(timer_col) + + lay = QVBoxLayout(self) + lay.setContentsMargins(14, 10, 14, 12) + lay.setSpacing(6) + lay.addWidget(self.title) + lay.addLayout(life_row) + lay.addWidget(self.counters) + lay.addWidget(self.zones) + lay.addWidget(self.commander) + lay.addWidget(self.emblems) + lay.addStretch(1) + + def render(self, g: GameState, seat: int, name: str, now_ms: int) -> None: + t = self.toggles + active = " · active" % theme.MINT_TEXT if g.active_player == seat else "" + play = "" + if t.on("play_draw") and g.on_play is not None: + on_play = g.on_play == seat + play = (f" · " + f"{'▶ on the play' if on_play else 'on the draw'}") + self.title.setText(f"{name}{active}{play}") + + life = g.life.get(seat) + self.life.setText(str(life) if life is not None else "–") + self.life.setVisible(t.on("life")) + self.life_caption.setVisible(t.on("life")) + + counters = {k: v for k, v in (g.player_counters.get(seat) or {}).items() if v} + self.counters.setText(" ".join(f"{v} {self.cards.counter_name(k).lower()}" + for k, v in sorted(counters.items())) + or f"No counters") + self.counters.setVisible(t.on("counters")) + + self.zones.setText( + f"Hand {g.zone_size('ZoneType_Hand', seat)} · " + f"Library {g.zone_size('ZoneType_Library', seat)} · " + f"Graveyard {g.zone_size('ZoneType_Graveyard', seat)} · " + # Exile is a shared zone, so count the seat's visible cards in it. + f"Exile {sum(g.cards_in('ZoneType_Exile', owner=seat).values())}" + ) + self.zones.setVisible(t.on("zones")) + + self.render_timer(g, seat, now_ms) + + drop = g.land_drop(seat) + if drop is None: + self.land.setText(f"Land drop –") + self.land.setToolTip("Lands can only be played on your own turn") + else: + used, allowed = drop + color = theme.MINT_TEXT if used < allowed else theme.MUTED + self.land.setText(f"Land drop {used}/{allowed}") + self.land.setToolTip(f"{used} of {allowed} land plays used this turn" + + (" (still available)" if used < allowed else "")) + self.land.setVisible(t.on("land_drop") and g.turn > 0) + + if g.max_timeouts and seat in g.timeouts_used: + self.timeouts.setText(f"Timeouts {g.timeouts_used[seat]}/{g.max_timeouts}") + elif g.max_timeouts: + self.timeouts.setText(f"Timeouts –/{g.max_timeouts}") + else: + self.timeouts.setText("") + self.timeouts.setVisible(t.on("timeouts") and bool(g.max_timeouts)) + + status = g.commander_status(seat) + if status: + tax = g.commander_tax(seat) + title = "Commander" + (f" (tax +{tax})" if tax else "") + self.commander.set_sections([Section("commander", title, [ + Row(self.cards.get(grp), 0, where or "hidden") for grp, where in status + ])]) + self.commander.setVisible(t.on("commander") and bool(status)) + + emblems = g.emblems(seat) + self.emblems.set_sections([Section("emblems", "Emblems", [ + Row(self.cards.get(grp), n) for grp, n in emblems.items() + ])] if emblems else []) + self.emblems.setVisible(t.on("emblems") and bool(emblems)) + + def render_timer(self, g: GameState, seat: int, now_ms: int) -> None: + timer = g.turn_timer(seat, now_ms) if not g.complete else None + if timer is None: + self.timer.setText("") + else: + remaining, warn, running = timer + if not running: + color = theme.MUTED + else: + color = theme.DANGER if remaining <= warn else theme.MINT_TEXT + self.timer.setText(f"" + f"⏱ {fmt_seconds(remaining)}") + self.timer.setToolTip("Turn timer running" if running else + "Time available before the turn timer runs out (starts when the rope appears)") + self.timer.setVisible(self.toggles.on("timer") and timer is not None) + + +class MatchView(QWidget): + hovered = Signal(int) + + def __init__(self, cards: CardDB, toggles: Toggles, parent=None): + super().__init__(parent) + self.cards = cards + self.toggles = toggles + self.me = PlayerCard(cards, toggles) + self.opp = PlayerCard(cards, toggles) + self.me.hovered.connect(self.hovered) + self.opp.hovered.connect(self.hovered) + + self.info = QFrame() + self.info.setObjectName("card") + self.turn_label = _label(name="bigNumber") + self.turn_caption = _label(name="muted") + self.phase = _label() + self.score = _label() + self.draws = _label(name="muted") + self.draws.setAlignment(Qt.AlignmentFlag.AlignCenter) + 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, + self.phase, self.score, self.draws, self.duration]): + grid.addWidget(w, row, 0, Qt.AlignmentFlag.AlignHCenter) + grid.setRowStretch(7, 1) + + top = QWidget() + top_lay = QHBoxLayout(top) + top_lay.setContentsMargins(0, 0, 0, 0) + top_lay.addWidget(self.me, 3) + 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]) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(split) + + def render(self, t: MatchTracker, now_ms: int) -> None: + g = t.game + for w in (self.me, self.opp, self.info): + w.setVisible(g is not None) + if g is None: + return + self.me.render(g, g.my_seat, "You", now_ms) + self.opp.render(g, g.opp_seat, t.opponent_name or "Opponent", now_ms) + + 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") + 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)) + wins = t.game_results.count("Won") + score = f"Game {g.game_number}" + if t.game_results: + score += f" · {wins}–{len(t.game_results) - wins}" + if not t.in_match and t.last_result: + score += f" · match {t.last_result.lower()}" + self.score.setText(score) + for w in (self.turn_label, self.turn_caption, self.phase, self.score): + w.setVisible(on("game_info")) + + # Best-of-three: who has been on the draw more often (the loser of a game chooses next). + draws = t.games_on_draw() + if g.best_of == 3 and t.on_play: + mine, theirs = draws.get(g.my_seat, 0), draws.get(g.opp_seat, 0) + more = "you" if mine > theirs else "they" if theirs > mine else "" + self.draws.setText(f"On the draw: you {mine} · them {theirs}" + + (f"
{more} had more games on the draw" if more else "
even so far")) + self.draws.setToolTip(" · ".join( + f"Game {n}: {'you' if seat == g.my_seat else 'they'} on the play" + for n, seat in sorted(t.on_play.items()))) + self.draws.setVisible(on("play_draw") and g.best_of == 3 and bool(t.on_play)) + self.tick(t, now_ms) + + def tick(self, t: MatchTracker, now_ms: int) -> None: + """Cheap per-second update of the clocks.""" + g = t.game + if g is None: + return + self.me.render_timer(g, g.my_seat, now_ms) + self.opp.render_timer(g, g.opp_seat, now_ms) + if g.started_ms is not None: + end = g.last_ms if g.complete or not t.in_match else max(now_ms, g.last_ms or now_ms) + self.duration.setText(f"Game time {fmt_seconds((end - g.started_ms) / 1000)}") + self.duration.setVisible(self.toggles.on("duration") and g.started_ms is not None) diff --git a/src/mtga_assistant/ui/theme.py b/src/mtga_assistant/ui/theme.py new file mode 100644 index 0000000..63e69c1 --- /dev/null +++ b/src/mtga_assistant/ui/theme.py @@ -0,0 +1,136 @@ +"""Static dark theme with a mint accent, rounded corners and larger text.""" + +from __future__ import annotations + +from PySide6.QtGui import QColor, QFont, QPalette +from PySide6.QtWidgets import QApplication, QStyleFactory + +BG = "#14171a" +SURFACE = "#1d2125" +SURFACE_2 = "#252a2f" +BORDER = "#2f353b" +TEXT = "#e6e9ec" +MUTED = "#8b949c" +MINT = "#3eb489" +MINT_HOVER = "#5fd3a6" +MINT_TEXT = "#7fe0b8" +DANGER = "#e07a6b" + +FONT_PT = 13 +RADIUS = 10 + +QSS = f""" +* {{ outline: none; }} +QMainWindow, QWidget#page {{ background: {BG}; }} +QToolTip {{ + background: {SURFACE_2}; color: {TEXT}; border: 1px solid {BORDER}; + border-radius: 6px; padding: 6px; +}} +QMenuBar {{ background: {BG}; color: {MUTED}; }} +QMenuBar::item:selected {{ background: {SURFACE_2}; color: {TEXT}; border-radius: 6px; }} +QMenu {{ + background: {SURFACE}; color: {TEXT}; border: 1px solid {BORDER}; + border-radius: {RADIUS}px; padding: 6px; +}} +QMenu::item {{ padding: 6px 18px 6px 12px; border-radius: 6px; }} +QMenu::item:selected {{ background: {SURFACE_2}; }} +QMenu::indicator {{ width: 14px; height: 14px; margin-left: 4px; }} +QMenu::indicator:checked {{ background: {MINT}; border-radius: 4px; }} +QMenu::indicator:unchecked {{ border: 1px solid {BORDER}; border-radius: 4px; }} +QMenu::separator {{ height: 1px; background: {BORDER}; margin: 6px 4px; }} + +QFrame#card {{ + background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; +}} +QLabel#cardTitle {{ color: {MINT_TEXT}; font-weight: bold; }} +QLabel#muted {{ color: {MUTED}; }} +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; +}} +QLabel#banner {{ + background: #3a2a24; color: {DANGER}; border: 1px solid #5a3a30; + border-radius: {RADIUS}px; padding: 8px 12px; +}} + +QPushButton, QToolButton {{ + background: {SURFACE_2}; color: {TEXT}; border: 1px solid {BORDER}; + border-radius: {RADIUS}px; padding: 6px 14px; +}} +QPushButton:hover, QToolButton:hover {{ border-color: {MINT}; }} +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; }} +QToolButton::menu-indicator {{ image: none; width: 0; }} + +QTreeWidget, QTableWidget, QListWidget {{ + background: {SURFACE}; alternate-background-color: {SURFACE_2}; + border: 1px solid {BORDER}; border-radius: {RADIUS}px; padding: 4px; + gridline-color: {BORDER}; +}} +QTreeWidget::item, QTableWidget::item {{ padding: 3px 2px; }} +QListWidget::item {{ padding: 6px 4px; border-radius: 6px; }} +QListWidget::item:hover {{ background: {SURFACE_2}; }} +QListWidget::item:selected {{ background: #24443a; color: {TEXT}; }} +QListWidget::indicator {{ width: 16px; height: 16px; border-radius: 4px; }} +QListWidget::indicator:unchecked {{ border: 1px solid {MUTED}; background: {BG}; }} +QListWidget::indicator:checked {{ border: 1px solid {MINT}; background: {MINT}; }} +QDialog {{ background: {BG}; }} +QTreeWidget::item:hover, QTableWidget::item:hover {{ background: {SURFACE_2}; }} +QTreeWidget::item:selected, QTableWidget::item:selected {{ background: #24443a; color: {TEXT}; }} +QHeaderView::section {{ + background: {SURFACE}; color: {MINT_TEXT}; border: none; + border-bottom: 1px solid {BORDER}; padding: 6px; font-weight: bold; +}} +QTableCornerButton::section {{ background: {SURFACE}; border: none; }} +QHeaderView {{ background: {SURFACE}; border: none; }} + +QSplitter::handle {{ background: {BG}; }} +QSplitter::handle:horizontal {{ width: 8px; }} +QSplitter::handle:vertical {{ height: 8px; }} + +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }} +QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 2px 4px; }} +QScrollBar::handle {{ background: {BORDER}; border-radius: 3px; min-height: 24px; min-width: 24px; }} +QScrollBar::handle:hover {{ background: {MINT}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }} +QScrollBar::add-page, QScrollBar::sub-page {{ background: none; }} + +QStatusBar {{ background: {BG}; color: {MUTED}; }} +QStatusBar QLabel {{ color: {MUTED}; }} +QStatusBar::item {{ border: none; }} +""" + + +def apply(app: QApplication) -> None: + app.setStyle(QStyleFactory.create("Fusion")) + pal = QPalette() + roles = { + QPalette.ColorRole.Window: BG, + QPalette.ColorRole.WindowText: TEXT, + QPalette.ColorRole.Base: SURFACE, + QPalette.ColorRole.AlternateBase: SURFACE_2, + QPalette.ColorRole.Text: TEXT, + QPalette.ColorRole.Button: SURFACE_2, + QPalette.ColorRole.ButtonText: TEXT, + QPalette.ColorRole.BrightText: MINT_TEXT, + QPalette.ColorRole.Highlight: MINT, + QPalette.ColorRole.HighlightedText: BG, + QPalette.ColorRole.ToolTipBase: SURFACE_2, + QPalette.ColorRole.ToolTipText: TEXT, + QPalette.ColorRole.PlaceholderText: MUTED, + QPalette.ColorRole.Link: MINT_TEXT, + QPalette.ColorRole.Mid: BORDER, + QPalette.ColorRole.Dark: BG, + } + for role, color in roles.items(): + pal.setColor(role, QColor(color)) + for role in (QPalette.ColorRole.Text, QPalette.ColorRole.WindowText, QPalette.ColorRole.ButtonText): + pal.setColor(QPalette.ColorGroup.Disabled, role, QColor(MUTED)) + app.setPalette(pal) + font = QFont(app.font()) + font.setPointSize(FONT_PT) + app.setFont(font) + app.setStyleSheet(QSS) diff --git a/src/mtga_assistant/ui/toggles.py b/src/mtga_assistant/ui/toggles.py new file mode 100644 index 0000000..d435929 --- /dev/null +++ b/src/mtga_assistant/ui/toggles.py @@ -0,0 +1,98 @@ +"""On/off switches and ordering for view elements, remembered across sessions.""" + +from __future__ import annotations + +from PySide6.QtCore import QObject, QSettings, Qt, Signal +from PySide6.QtGui import QAction, QKeyEvent, QMouseEvent +from PySide6.QtWidgets import QMenu + + +class StayOpenMenu(QMenu): + """A menu that stays open when a checkable item is toggled, so several can be changed at once. + + It still closes on a click outside it, on Escape, or on a non-checkable item. + """ + + def _toggle_in_place(self, action: QAction | None) -> bool: + if action is None or not action.isCheckable() or not action.isEnabled() or action.menu(): + return False + action.trigger() + return True + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if event.button() == Qt.MouseButton.LeftButton and self._toggle_in_place(self.actionAt(event.position().toPoint())): + return + super().mouseReleaseEvent(event) + + def keyPressEvent(self, event: QKeyEvent) -> None: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space) \ + and self._toggle_in_place(self.activeAction()): + return + super().keyPressEvent(event) + + +class Toggles(QObject): + changed = Signal() + + def __init__(self, group: str, items: list[tuple[str, str]], defaults_off: set[str] = frozenset(), + title: str = "", parent=None): + super().__init__(parent) + self.group = group + self.title = title + self.items = items + self.labels = dict(items) + self._settings = QSettings("mtga-assistant", "mtga-assistant") + self._state = { + key: self._settings.value(f"{group}/{key}", key not in defaults_off, type=bool) + for key, _ in items + } + saved = self._settings.value(f"{group}/_order", []) or [] + if isinstance(saved, str): # QSettings returns a one-item list as a plain string + saved = [saved] + self._order = self._merge_order([k for k in saved if k in self.labels]) + + def _merge_order(self, saved: list[str]) -> list[str]: + """Saved order, with keys that are new since it was saved inserted at their default spot.""" + order = list(saved) + defaults = [k for k, _ in self.items] + for i, key in enumerate(defaults): + if key not in order: + # Place after the nearest preceding default key that is already placed. + before = [k for k in defaults[:i] if k in order] + order.insert(order.index(before[-1]) + 1 if before else 0, key) + return order + + def on(self, key: str) -> bool: + return self._state.get(key, True) + + def order(self) -> list[str]: + return list(self._order) + + def ordered_items(self) -> list[tuple[str, str]]: + return [(k, self.labels[k]) for k in self._order] + + def set(self, key: str, value: bool) -> None: + self._state[key] = value + self._settings.setValue(f"{self.group}/{key}", value) + self.changed.emit() + + def apply(self, order: list[str], visible: dict[str, bool]) -> None: + """Set order and visibility together (one change notification).""" + self._order = self._merge_order([k for k in order if k in self.labels]) + self._settings.setValue(f"{self.group}/_order", self._order) + for key, value in visible.items(): + if key in self._state: + self._state[key] = value + self._settings.setValue(f"{self.group}/{key}", value) + self.changed.emit() + + def reset(self) -> None: + self.apply([k for k, _ in self.items], {k: True for k, _ in self.items}) + + def fill_menu(self, menu: QMenu) -> None: + for key, label in self.ordered_items(): + act = QAction(label, menu) + act.setCheckable(True) + act.setChecked(self.on(key)) + act.toggled.connect(lambda checked, k=key: self.set(k, checked)) + menu.addAction(act) diff --git a/src/mtga_assistant/ui/turn_view.py b/src/mtga_assistant/ui/turn_view.py new file mode 100644 index 0000000..a81fa44 --- /dev/null +++ b/src/mtga_assistant/ui/turn_view.py @@ -0,0 +1,187 @@ +"""Turn view: per-player event counts for a chosen turn and for the whole game.""" + +from __future__ import annotations + +from collections import Counter + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from ..gamestate import GameState, MatchTracker +from ..turnstats import STATS +from . import theme +from .deck_view import card_frame +from .toggles import Toggles + +LAND_DROP = "land_drop" +TURN_TOGGLES = [(LAND_DROP, "Land drop (played/allowed)")] + STATS +TURN_DEFAULTS_OFF = {"permanents_died", "energy_spent"} + + +class StatsTable(QTableWidget): + def __init__(self, parent=None): + super().__init__(0, 3, parent) + self.setHorizontalHeaderLabels(["You", "Opponent", "Total"]) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.setShowGrid(False) + self.setAlternatingRowColors(True) + self.verticalHeader().setDefaultSectionSize(34) + self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed) + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.verticalHeader().setStyleSheet(f"QHeaderView::section {{ color: {theme.TEXT}; font-weight: 400;" + f" padding: 0 12px; border: none; }}") + + def fill(self, rows: list[tuple[str, list[int | str | None]]]) -> None: + """rows: (label, [you, opponent, total]). Ints: 0 is dimmed. Strings shown as-is; None = n/a.""" + self.setRowCount(len(rows)) + self.setVerticalHeaderLabels([label for label, _ in rows]) + for r, (_label, values) in enumerate(rows): + for c, v in enumerate(values): + empty = v is None or v == 0 + item = QTableWidgetItem("·" if v == 0 else "–" if v is None else str(v)) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + if empty: + item.setForeground(QColor(theme.BORDER)) + elif c == 2: + item.setForeground(QColor(theme.MINT_TEXT)) + self.setItem(r, c, item) + + +def counts_row(counts: Counter[int], me: int, opp: int) -> list[int]: + return [counts.get(me, 0), counts.get(opp, 0), counts.get(me, 0) + counts.get(opp, 0)] + + +def fraction(used: int, allowed: int) -> str: + return f"{used}/{allowed}" + + +class TurnView(QWidget): + def __init__(self, toggles: Toggles, parent=None): + super().__init__(parent) + self.toggles = toggles + self.selected: int | None = None # None = follow the live turn + self._tracker: MatchTracker | None = None + + self.prev_btn = QPushButton("◀") + self.next_btn = QPushButton("▶") + self.live_btn = QPushButton("Live") + self.live_btn.setCheckable(True) + self.live_btn.setChecked(True) + self.title = QLabel() + self.title.setObjectName("cardTitle") + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.title.setMinimumWidth(260) + 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) + + 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.empty = QLabel("Waiting for a game…") + self.empty.setObjectName("muted") + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addLayout(nav) + lay.addLayout(tables, 1) + lay.addWidget(self.empty, 1) + + def _turns(self, g: GameState) -> list[int]: + return [n for n in g.turns.turn_numbers() if n > 0] or ([g.turn] if g.turn else []) + + def _step(self, delta: int) -> None: + if self._tracker is None or self._tracker.game is None: + return + g = self._tracker.game + turns = self._turns(g) + if not turns: + return + current = self.selected if self.selected is not None else g.turn + idx = max(0, min(len(turns) - 1, (turns.index(current) if current in turns else len(turns) - 1) + delta)) + self.selected = turns[idx] + if self.selected == turns[-1]: + self.selected = None # stepping onto the newest turn resumes following it + self.live_btn.setChecked(self.selected is None) + self.render(self._tracker) + + def _set_live(self, live: bool) -> None: + if live: + self.selected = None + elif self.selected is None and self._tracker and self._tracker.game: + self.selected = self._tracker.game.turn + if self._tracker: + self.render(self._tracker) + + def render(self, t: MatchTracker) -> None: + self._tracker = t + 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): + w.setVisible(has_game) + if g is None: + return + + turns = self._turns(g) + if self.selected is not None and self.selected not in turns: + 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") + self.prev_btn.setEnabled(bool(turns) and shown != turns[0]) + self.next_btn.setEnabled(self.selected is not None) + + enabled = [(key, label) for key, label in self.toggles.ordered_items() if self.toggles.on(key)] + turn_stats = g.turns.turn(shown) + totals = g.turns.game_totals() + me, opp = g.my_seat, g.opp_seat + + def turn_row(key: str) -> list: + if key == LAND_DROP: + # Only the active player can play lands; show used/allowed for them. + drop = g.turns.land_drop(shown) if shown else None + active = g.turns.active.get(shown) + if drop is None: + return [None, None, None] + cell = fraction(*drop) + return [cell if active == me else None, cell if active == opp else None, cell] + return counts_row(turn_stats.get(key, Counter()), me, opp) + + def game_row(key: str) -> list: + if key == LAND_DROP: + mine, theirs = g.turns.land_drops_total(me), g.turns.land_drops_total(opp) + return [fraction(*mine), fraction(*theirs), fraction(mine[0] + theirs[0], mine[1] + theirs[1])] + return counts_row(totals.get(key, Counter()), me, opp) + + self.turn_table.fill([(label, turn_row(key)) for key, label in enabled]) + self.game_table.fill([(label, game_row(key)) for key, label in enabled]) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 90aa89c..391ef10 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -194,3 +194,34 @@ def test_object_logged_before_its_id_change_is_one_card(): t.handle(GreMessages([gsm(3, objects=[card(220, 5005, BATTLEFIELD)])])) t.handle(GreMessages([gsm(4, annotations=[id_changed(210, 220)])])) assert t.opp_seen_match == {5005: 1} + + +def test_commanders_are_not_emblems(): + COMMAND, STACK = 26, 27 + t = MatchTracker() + t.handle(GreMessages([{ + "type": "GREMessageType_ConnectResp", "systemSeatIds": [ME], + "connectResp": {"deckMessage": {"deckCards": [1001] * 3, "commanderCards": [8000]}}, + }])) + t.handle(GreMessages([gsm(1, full=True, zones=[ + zone(COMMAND, "Command", [50, 51, 52]), zone(STACK, "Stack", []), zone(BATTLEFIELD, "Battlefield", []), + zone(MY_LIB, "Library", [100, 101, 102], ME), zone(OPP_HAND, "Hand", [], OPP), + ], objects=[card(50, 8000, COMMAND, ME), card(51, 9000, COMMAND, OPP), + card(52, 7777, COMMAND, OPP, otype="Emblem")])])) + g = t.game + assert g.commander_status(ME) == [(8000, "command zone")] + assert g.commander_status(OPP) == [(9000, "command zone")] + assert g.emblems(OPP) == {7777: 1} + assert g.emblems(ME) == {} + assert sum(t.my_library_remaining().values()) == 3 # the commander isn't part of the library + + # Opponent casts their commander from the command zone: it's on the stack, tax goes up. + cast = {"type": ["AnnotationType_ZoneTransfer"], "affectedIds": [60], "details": [ + {"key": "zone_src", "valueInt32": [COMMAND]}, {"key": "zone_dest", "valueInt32": [STACK]}, + {"key": "category", "valueString": ["CastSpell"]}]} + t.handle(GreMessages([gsm(2, annotations=[id_changed(51, 60), cast], zones=[ + zone(COMMAND, "Command", [50, 52]), zone(STACK, "Stack", [60]), + ], objects=[card(60, 9000, STACK, OPP)], deleted=[51])])) + assert g.commander_status(OPP) == [(9000, "stack")] + assert g.commander_tax(OPP) == 2 + assert g.commander_tax(ME) == 0 diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..fff475f --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,67 @@ +from mtga_assistant.history import HistoryRecorder, HistoryStore, decode, encode, import_log, replay_game +from mtga_assistant.logparse import LogParser + +from test_gamestate import FIXTURE, ME + + +def parse(path): + p = LogParser() + with open(path, encoding="utf-8") as f: + return [ev for line in f for ev in p.feed(line)] + + +def test_events_round_trip(): + for ev in parse(FIXTURE): + enc = encode(ev) + if enc is None: + continue + back = decode(enc) + assert type(back) is type(ev) + + +def test_import_records_each_game_once(tmp_path): + store = HistoryStore(tmp_path / "h.db") + assert import_log(FIXTURE, store, None) == 1 + assert import_log(FIXTURE, store, None) == 0 # already recorded + games = store.games() + assert [g.game_number for g in games] == [2, 1] # newest first + g1 = games[1] + assert g1.opponent == "Opponent Player" + assert g1.result == "Won" and games[0].result == "Won" + assert g1.best_of == 3 + assert g1.on_play == "me" + assert g1.deck_name == "Cruel Tutelage" + assert g1.replayable + + +def test_replay_stops_at_the_requested_game(tmp_path): + store = HistoryStore(tmp_path / "h.db") + import_log(FIXTURE, store, None) + events = store.events(store.games()[0].match_id) + t1 = replay_game(events, 1, None) + assert t1.game.game_number == 1 + assert t1.game.on_play == ME + assert t1.game_results == ["Won"] + t2 = replay_game(events, 2, None) + assert t2.game.game_number == 2 + assert t2.game_results == ["Won", "Won"] + assert t2.games_on_draw() == {2: 1} + + +def test_events_pruned_beyond_cap_but_summaries_kept(tmp_path): + store = HistoryStore(tmp_path / "h.db", max_events_mb=0) + import_log(FIXTURE, store, None) + games = store.games() + assert len(games) == 2 and not any(g.replayable for g in games) + assert store.events(games[0].match_id) == [] + + +def test_recorder_ignores_unfinished_match(tmp_path): + from mtga_assistant.gamestate import MatchTracker + store = HistoryStore(tmp_path / "h.db") + rec, t = HistoryRecorder(store), MatchTracker() + events = parse(FIXTURE) + for ev in events[:-3]: # stop before the match completes + t.handle(ev) + rec.observe(ev, t) + assert store.games() == [] diff --git a/tests/test_toggles.py b/tests/test_toggles.py new file mode 100644 index 0000000..b74b7c9 --- /dev/null +++ b/tests/test_toggles.py @@ -0,0 +1,41 @@ +import pytest +from PySide6.QtCore import QSettings + +from mtga_assistant.ui.toggles import Toggles + +ITEMS = [("a", "A"), ("b", "B"), ("c", "C")] + + +@pytest.fixture(autouse=True) +def isolated_settings(tmp_path): + QSettings.setPath(QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path)) + yield + + +def test_default_order_and_visibility(): + t = Toggles("g1", ITEMS, defaults_off={"b"}) + assert t.order() == ["a", "b", "c"] + assert not t.on("b") and t.on("a") + + +def test_order_and_visibility_persist(): + t = Toggles("g2", ITEMS) + t.apply(["c", "a", "b"], {"a": False}) + again = Toggles("g2", ITEMS) + assert again.order() == ["c", "a", "b"] + assert not again.on("a") and again.on("c") + assert [k for k, _ in again.ordered_items()] == ["c", "a", "b"] + + +def test_new_items_slot_in_after_their_default_neighbour(): + Toggles("g3", ITEMS).apply(["c", "a", "b"], {}) + grown = Toggles("g3", [("a", "A"), ("new", "New"), ("b", "B"), ("c", "C")]) + assert grown.order() == ["c", "a", "new", "b"] + + +def test_unknown_saved_keys_are_dropped_and_reset_restores_defaults(): + t = Toggles("g4", ITEMS) + t.apply(["gone", "b", "a", "c"], {"b": False}) + assert t.order() == ["b", "a", "c"] + t.reset() + assert t.order() == ["a", "b", "c"] and t.on("b") diff --git a/tests/test_turnstats.py b/tests/test_turnstats.py new file mode 100644 index 0000000..a245a66 --- /dev/null +++ b/tests/test_turnstats.py @@ -0,0 +1,214 @@ +from mtga_assistant.gamestate import MatchTracker +from mtga_assistant.logparse import GreMessages +from mtga_assistant.turnstats import COUNTER_ENERGY, COUNTER_POISON + +from test_gamestate import ( + BATTLEFIELD, FIXTURE, ME, MY_HAND, MY_LIB, OPP, OPP_GY, OPP_HAND, OPP_LIB, card, gsm, id_changed, replay, + start_game, zone, +) + +STACK = 27 + + +def transfer(iid: int, src: int, dest: int, category: str) -> dict: + return {"type": ["AnnotationType_ZoneTransfer"], "affectedIds": [iid], "details": [ + {"key": "zone_src", "valueInt32": [src]}, {"key": "zone_dest", "valueInt32": [dest]}, + {"key": "category", "valueString": [category]}]} + + +def life(seat: int, delta: int) -> dict: + return {"type": ["AnnotationType_ModifiedLife"], "affectedIds": [seat], + "details": [{"key": "life", "valueInt32": [delta]}]} + + +def damage(seat: int, n: int) -> dict: + return {"type": ["AnnotationType_DamageDealt"], "affectedIds": [seat], + "details": [{"key": "damage", "valueInt32": [n]}]} + + +def counter_added(seat: int, ctype: int, n: int, removed: bool = False) -> dict: + kind = "AnnotationType_CounterRemoved" if removed else "AnnotationType_CounterAdded" + return {"type": [kind], "affectedIds": [seat], "details": [ + {"key": "counter_type", "valueInt32": [ctype]}, {"key": "transaction_amount", "valueInt32": [n]}]} + + +def with_turn(msg: dict, turn: int, active: int) -> dict: + msg["gameStateMessage"]["turnInfo"] = {"turnNumber": turn, "activePlayer": active, "phase": "Phase_Main1"} + return msg + + +def creature(iid: int, grp: int, zid: int, owner: int) -> dict: + obj = card(iid, grp, zid, owner) + obj["cardTypes"] = ["CardType_Creature"] + return obj + + +def test_turn_events_are_attributed_per_player_and_turn(): + t = MatchTracker() + start_game(t) + # Turn 1 (mine): draw, cast a creature that resolves. + t.handle(GreMessages([with_turn(gsm(2, annotations=[ + id_changed(100, 150), transfer(150, MY_LIB, MY_HAND, "Draw"), + ], zones=[zone(MY_LIB, "Library", [101, 102, 103], ME), zone(MY_HAND, "Hand", [150], ME)], + objects=[card(150, 1001, MY_HAND, ME)]), 1, ME)])) + t.handle(GreMessages([gsm(3, annotations=[ + id_changed(150, 151), transfer(151, MY_HAND, STACK, "CastSpell"), + ], zones=[zone(MY_HAND, "Hand", [], ME), zone(STACK, "Stack", [151])], + objects=[creature(151, 1001, STACK, ME)])])) + # Turn 2 (theirs): they draw (hidden), play a land, lose 3 life and take 2 damage. + t.handle(GreMessages([with_turn(gsm(4, annotations=[ + id_changed(300, 310), transfer(310, OPP_LIB, OPP_HAND, "Draw"), + id_changed(200, 210), transfer(210, OPP_HAND, BATTLEFIELD, "PlayLand"), + life(OPP, -3), damage(OPP, 2), + ], zones=[zone(OPP_LIB, "Library", [301, 302], OPP), zone(OPP_HAND, "Hand", [201, 310], OPP), + zone(BATTLEFIELD, "Battlefield", [210])], + objects=[card(210, 5005, BATTLEFIELD)]), 2, OPP)])) + # Their creature dies on turn 2 as well. + t.handle(GreMessages([gsm(5, annotations=[ + id_changed(210, 220), transfer(220, BATTLEFIELD, OPP_GY, "Destroy"), life(ME, 4), + ], zones=[zone(BATTLEFIELD, "Battlefield", []), zone(OPP_GY, "Graveyard", [220], OPP)], + objects=[creature(220, 5005, OPP_GY, OPP)])])) + + log = t.game.turns + assert log.turn(1)["drawn"] == {ME: 1} + assert log.turn(1)["cast"] == {ME: 1} + t2 = log.turn(2) + assert t2["drawn"] == {OPP: 1} + assert t2["lands"] == {OPP: 1} + assert t2["life_lost"] == {OPP: 3} + assert t2["damage_taken"] == {OPP: 2} + assert t2["creatures_died"] == {OPP: 1} + assert t2["life_gained"] == {ME: 4} + assert log.active == {1: ME, 2: OPP} + + totals = log.game_totals() + assert totals["drawn"] == {ME: 1, OPP: 1} + assert log.turn_numbers() == [1, 2] + + +def test_player_counters_and_counter_stats(): + t = MatchTracker() + start_game(t) + t.handle(GreMessages([with_turn(gsm(2, annotations=[ + counter_added(OPP, COUNTER_POISON, 3), counter_added(ME, COUNTER_ENERGY, 2), + ]), 3, ME)])) + t.handle(GreMessages([gsm(3, annotations=[counter_added(ME, COUNTER_ENERGY, 1, removed=True)])])) + g = t.game + assert g.player_counters[OPP][COUNTER_POISON] == 3 + assert g.player_counters[ME][COUNTER_ENERGY] == 1 + stats = g.turns.turn(3) + assert stats["poison_gained"] == {OPP: 3} + assert stats["energy_gained"] == {ME: 2} + assert stats["energy_spent"] == {ME: 1} + + +def test_turn_timer_counts_down_from_message_time(): + t = MatchTracker() + start_game(t) + msg = gsm(2) + msg["gameStateMessage"]["players"] = [{"systemSeatNumber": ME, "timerIds": [3]}, + {"systemSeatNumber": OPP, "timerIds": [9]}] + msg["gameStateMessage"]["timers"] = [{"timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 60, + "elapsedMs": 10_000, "running": True, "warningThresholdSec": 30}] + t.handle(GreMessages([msg], timestamp_ms=1_000_000)) + g = t.game + assert g.turn_timer(ME, 1_000_000) == (50.0, 30.0, True) + assert g.turn_timer(ME, 1_005_000) == (45.0, 30.0, True) + assert g.turn_timer(OPP, 1_005_000) is None + # A stopped timer reports its remaining time without counting down. + msg = gsm(3) + msg["gameStateMessage"]["timers"] = [{"timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 61, + "warningThresholdSec": 30}] + t.handle(GreMessages([with_turn(msg, 1, ME)], timestamp_ms=1_010_000)) + assert g.turn_timer(ME, 1_090_000) == (61.0, 30.0, False) + assert g.started_ms == 1_000_000 + + +def test_fixture_draws_match_draw_transfers(): + t = MatchTracker() + replay(FIXTURE, t) + g = t.game + drawn = g.turns.game_totals().get("drawn", {}) + # Game 2 of the fixture: the player on the draw draws every turn they take. + my_turns = sum(1 for seat in g.turns.active.values() if seat == ME) + assert drawn.get(ME, 0) >= my_turns - 1 + + +# -- land drops ------------------------------------------------------------------ + +from mtga_assistant.carddb import CardInfo, LandRule # noqa: E402 + +STATIC_EXTRA, EACH_PLAYER, THIS_TURN = 13760, 1169, 6426 +EXPLORE_GRP = 4242 # a sorcery whose ability grants one extra land this turn + + +class LandCards: + def get(self, grp): + return CardInfo(grp_id=grp, name=str(grp)) + + def base_grp(self, grp): + return grp + + def land_rules(self): + return {STATIC_EXTRA: LandRule(1, False, False), EACH_PLAYER: LandRule(1, False, True), + THIS_TURN: LandRule(1, True, False)} + + def ability_ids(self, grp): + return (THIS_TURN,) if grp == EXPLORE_GRP else () + + +def with_abilities(obj: dict, *ability_ids: int) -> dict: + obj["uniqueAbilities"] = [{"id": i, "grpId": a} for i, a in enumerate(ability_ids)] + return obj + + +def resolved(affector: int, grp: int) -> dict: + return {"type": ["AnnotationType_ResolutionComplete"], "affectorId": affector, "affectedIds": [affector], + "details": [{"key": "grpid", "valueInt32": [grp]}]} + + +def play_land(state_id: int, old: int, new: int, owner: int, hand: int) -> dict: + return gsm(state_id, annotations=[id_changed(old, new), transfer(new, hand, BATTLEFIELD, "PlayLand")], + objects=[card(new, 1001, BATTLEFIELD, owner)]) + + +def test_land_drop_basic_turn(): + t = MatchTracker(card_lookup=LandCards()) + start_game(t) + t.handle(GreMessages([with_turn(gsm(2), 1, ME)])) + g = t.game + assert g.land_drop(ME) == (0, 1) + assert g.land_drop(OPP) is None # not their turn + t.handle(GreMessages([play_land(3, 100, 150, ME, MY_HAND)])) + assert g.land_drop(ME) == (1, 1) + assert g.turns.land_drop(1) == (1, 1) + + +def test_static_and_one_shot_extra_land_plays(): + t = MatchTracker(card_lookup=LandCards()) + start_game(t) + # Turn 1: I control a permanent with "play an additional land on each of your turns". + t.handle(GreMessages([with_turn(gsm(2, zones=[zone(BATTLEFIELD, "Battlefield", [500])], + objects=[with_abilities(card(500, 9001, BATTLEFIELD, ME), STATIC_EXTRA)]), + 1, ME)])) + g = t.game + assert g.land_drop(ME) == (0, 2) + # Then I resolve a spell granting an additional land this turn. + t.handle(GreMessages([gsm(3, annotations=[resolved(501, EXPLORE_GRP)], + objects=[card(501, EXPLORE_GRP, 27, ME)])])) + assert g.land_drop(ME) == (0, 3) + t.handle(GreMessages([play_land(4, 100, 150, ME, MY_HAND)])) + assert g.land_drop(ME) == (1, 3) + + # Turn 2 (theirs): my static doesn't help them; the one-shot is gone. + t.handle(GreMessages([with_turn(gsm(5), 2, OPP)])) + assert g.land_drop(OPP) == (0, 1) + # An "each player" effect on the battlefield helps them too. + t.handle(GreMessages([gsm(6, zones=[zone(BATTLEFIELD, "Battlefield", [500, 502])], + objects=[with_abilities(card(502, 9002, BATTLEFIELD, ME), EACH_PLAYER)])])) + assert g.land_drop(OPP) == (0, 2) + + # History keeps what each turn allowed. + assert g.turns.land_drop(1) == (1, 3) + assert g.turns.land_drop(2) == (0, 2) + assert g.turns.land_drops_total(ME) == (1, 3)