Add match, turn and history views with a dark theme

- Top bar switching between Deck, Match, Turn and History views
- Match view: life, player counters, commanders, emblems, zone counts,
  turn timer and timeouts, land drops used/allowed, play/draw and Bo3
  draw counts, with the deck view below
- Turn view: per-player and total stats per turn with history, plus
  whole-game totals and land drops
- History: every finished game (colours, winner, who was on the play);
  click to reopen it in the other views; imports Player-prev.log
- Customize menu to show/hide items and an Arrange dialog to reorder
  deck sections and turn stats, remembered across sessions
- Static dark theme with mint accent, rounded corners, larger text
- Commanders tracked separately from emblems, with location and tax
- Quieter, non-sticky warning for cards lost to MTGA log gaps
- Correct Scryfall images for tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 11:18:44 -04:00
1 parent 7dc2ce0da8
commit 5b3c8c8e63
22 files changed
+2589 -183

No files matched your search

+26 -3
View File
@@ -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.
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
- *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).
- *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
+10 -2
View File
@@ -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()
+69 -2
View File
@@ -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)
+240 -9
View File
@@ -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:
+279
View File
@@ -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 ``<data dir>/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
+14 -4
View File
@@ -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<set>".
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)
+6 -1
View File
@@ -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 {}
+4
View File
@@ -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
+162
View File
@@ -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)
+106
View File
@@ -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()
+6 -3
View File
@@ -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)
+170
View File
@@ -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))
+147
View File
@@ -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))
+289 -159
View File
@@ -1,130 +1,264 @@
"""Main window: my deck | opponent | card preview."""
"""Main window: a top bar switching between the Deck, Match and Turn views."""
from __future__ import annotations
from collections import Counter
import time
from pathlib import Path
from PySide6.QtCore import Qt, QTimer
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QButtonGroup,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMenu,
QMessageBox,
QSplitter,
QPushButton,
QStackedWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
from ..carddb import CardDB
from ..gamestate import GameState, MatchTracker
from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
from ..images import ImageCache
from ..logtail import LogFollower
from .card_list import CardList, Row, Section
from .preview import CardPreview
from . import theme
from .arrange import ArrangeDialog
from .deck_view import MY_SECTIONS, OPP_SECTIONS, DeckView
from .history_view import HistoryView, fmt_when
from .match_view import MATCH_TOGGLES, MatchView
from .toggles import StayOpenMenu, Toggles
from .turn_view import TURN_DEFAULTS_OFF, TURN_TOGGLES, TurnView
REFRESH_MS = 150
DECK_TOGGLES = [("preview", "Card preview pane")]
VIEWS = ["Deck", "Match", "Turn", "History"]
HISTORY = 3
def _panel(title: str, body: QWidget) -> QWidget:
w = QWidget()
lay = QVBoxLayout(w)
lay.setContentsMargins(4, 4, 4, 4)
label = QLabel(f"<b>{title}</b>")
lay.addWidget(label)
lay.addWidget(body, 1)
return w
class HistoryImport(QThread):
"""Records completed matches from an older log (Player-prev.log) in the background."""
done = Signal(int)
def __init__(self, path: Path, store: HistoryStore, cards: CardDB):
super().__init__()
self.path, self.store, self.cards = path, store, cards
def run(self) -> None:
try:
st = self.path.stat()
except OSError:
return
stamp = f"{st.st_size}:{st.st_mtime_ns}"
key = f"imported:{self.path}"
if self.store.meta(key) == stamp:
return
# Own card DB connection: this runs off the GUI thread.
saved = import_log(self.path, self.store, CardDB(self.cards.path))
self.store.set_meta(key, stamp)
self.done.emit(saved)
class MainWindow(QMainWindow):
def __init__(self, cards: CardDB, images: ImageCache, log_path: Path | None,
def __init__(self, cards: CardDB, images: ImageCache, history: HistoryStore, log_path: Path | None,
replay_rate: float | None = None):
super().__init__()
self.setWindowTitle("MTGA Assistant")
self.cards = cards
self.images = images
self.history = history
self.recorder = HistoryRecorder(history)
self.tracker = MatchTracker(card_lookup=cards)
# A past game opened from the History view: (tracker, summary text). None = live.
self.past: tuple[MatchTracker, str] | None = None
self._importers: list[HistoryImport] = []
self.follower: LogFollower | None = None
self._rendered_version = -1
self.replaying = False
self._rendered: dict[int, int] = {} # view index -> tracker version last rendered
self._settings = QSettings("mtga-assistant", "mtga-assistant")
# Header
self.toggles = [
Toggles("deck", DECK_TOGGLES, parent=self),
Toggles("match", MATCH_TOGGLES, parent=self),
Toggles("turn", TURN_TOGGLES, TURN_DEFAULTS_OFF, parent=self),
]
self.my_sections = Toggles("deck_mine", MY_SECTIONS, title="My deck", parent=self)
self.opp_sections = Toggles("deck_opp", OPP_SECTIONS, title="Opponent", parent=self)
self.toggles[2].title = "Turn stats"
for tg in (*self.toggles, self.my_sections, self.opp_sections):
tg.changed.connect(self._force_refresh)
# Views. One DeckView is shared by the Deck page and the bottom half of the Match page.
self.deck_view = DeckView(cards, images, self.my_sections, self.opp_sections)
self.deck_page = QWidget()
self.deck_page_layout = QVBoxLayout(self.deck_page)
self.deck_page_layout.setContentsMargins(0, 0, 0, 0)
self.match_view = MatchView(cards, self.toggles[1])
self.match_view.hovered.connect(self.deck_view.preview.show_card)
self.turn_view = TurnView(self.toggles[2])
self.history_view = HistoryView()
self.history_view.open_game.connect(self.open_past_game)
self.stack = QStackedWidget()
for page in (self.deck_page, self.match_view, self.turn_view, self.history_view):
self.stack.addWidget(page)
# Top bar
self.nav = QButtonGroup(self)
self.nav.setExclusive(True)
bar = QHBoxLayout()
bar.setSpacing(8)
for i, name in enumerate(VIEWS):
btn = QPushButton(name)
btn.setObjectName("navButton")
btn.setCheckable(True)
btn.setShortcut(QKeySequence(f"Ctrl+{i + 1}"))
btn.setToolTip(f"{name} view (Ctrl+{i + 1})")
self.nav.addButton(btn, i)
bar.addWidget(btn)
self.nav.idClicked.connect(self.show_view)
bar.addSpacing(16)
self.header = QLabel("Waiting for a match…")
self.header.setTextFormat(Qt.TextFormat.RichText)
bar.addWidget(self.header, 1)
self.customize = QToolButton()
self.customize.setText("⚙ Customize")
self.customize.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.customize_menu = StayOpenMenu(self)
self.customize_menu.aboutToShow.connect(self._fill_customize)
self.customize.setMenu(self.customize_menu)
bar.addWidget(self.customize)
bar.addWidget(self._app_menu_button())
self.warning = QLabel()
self.warning.setStyleSheet("color: #d08770;")
self.warning.setObjectName("banner")
self.warning.setWordWrap(True)
self.warning.hide()
# Panels
self.mine = CardList()
self.opp = CardList()
self.preview = CardPreview(cards, images)
self.mine.hovered.connect(self.preview.show_card)
self.opp.hovered.connect(self.preview.show_card)
split = QSplitter(Qt.Orientation.Horizontal)
split.addWidget(_panel("My deck", self.mine))
split.addWidget(_panel("Opponent", self.opp))
split.addWidget(self.preview)
split.setStretchFactor(0, 3)
split.setStretchFactor(1, 3)
split.setStretchFactor(2, 2)
# Shown while looking at a past game.
self.past_bar = QFrame()
self.past_bar.setObjectName("pastBar")
past_lay = QHBoxLayout(self.past_bar)
past_lay.setContentsMargins(12, 6, 8, 6)
self.past_label = QLabel()
self.past_label.setTextFormat(Qt.TextFormat.RichText)
back = QPushButton("Back to live")
back.clicked.connect(self.close_past_game)
past_lay.addWidget(self.past_label, 1)
past_lay.addWidget(back)
self.past_bar.hide()
central = QWidget()
central.setObjectName("page")
lay = QVBoxLayout(central)
top = QHBoxLayout()
top.addWidget(self.header, 1)
lay.addLayout(top)
lay.setContentsMargins(12, 10, 12, 6)
lay.setSpacing(10)
lay.addLayout(bar)
lay.addWidget(self.warning)
lay.addWidget(split, 1)
lay.addWidget(self.past_bar)
lay.addWidget(self.stack, 1)
self.setCentralWidget(central)
# Status bar
self.status_log = QLabel()
self.status_cache = QLabel()
self.status_gap = QLabel()
self.status_gap.setStyleSheet(f"color: {theme.DANGER};")
self.status_gap.hide()
self.statusBar().addWidget(self.status_log, 1)
self.statusBar().addPermanentWidget(self.status_gap)
self.statusBar().addPermanentWidget(self.status_cache)
images.size_changed.connect(self._on_cache_size)
self._on_cache_size(images.disk_usage())
if not cards.available:
self.warning.setText("MTGA card database not found: card names are unavailable. "
"Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
self.warning.show()
self._build_menu()
self.refresh_timer = QTimer(self)
self.refresh_timer.setInterval(REFRESH_MS)
self.refresh_timer.timeout.connect(self.refresh)
self.refresh_timer.start()
self.clock_timer = QTimer(self)
self.clock_timer.setInterval(1000)
self.clock_timer.timeout.connect(self._tick)
self.clock_timer.start()
self.resize(1280, 800)
self.resize(1400, 880)
self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view", 0, type=int)))
if log_path is not None:
self.start_log(log_path, replay_rate)
else:
self.status_log.setText("Player.log not found: use File → Open log…")
self.status_log.setText("Player.log not found: use ☰ → Open log…")
# -- menu ----------------------------------------------------------------
# -- top bar -------------------------------------------------------------
def _build_menu(self) -> None:
file_menu = self.menuBar().addMenu("&File")
open_act = QAction("&Open log…", self)
def _app_menu_button(self) -> QToolButton:
btn = QToolButton()
btn.setText("☰")
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
menu = QMenu(self)
open_act = menu.addAction("Open log…")
open_act.triggered.connect(lambda: self._choose_log(None))
replay_act = QAction("&Replay log…", self)
replay_act = menu.addAction("Replay log…")
replay_act.triggered.connect(lambda: self._choose_log(40.0))
quit_act = QAction("&Quit", self)
menu.addSeparator()
clear_act = menu.addAction("Clear image cache")
clear_act.triggered.connect(self._clear_cache)
menu.addSeparator()
quit_act = QAction("Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
file_menu.addActions([open_act, replay_act])
file_menu.addSeparator()
file_menu.addAction(quit_act)
self.addAction(quit_act) # keep the shortcut active while the menu is closed
menu.addAction(quit_act)
btn.setMenu(menu)
return btn
cache_menu = self.menuBar().addMenu("&Cache")
clear_act = QAction("&Clear image cache", self)
clear_act.triggered.connect(self._clear_cache)
cache_menu.addAction(clear_act)
def _arrangeable(self) -> list[Toggles]:
"""Toggle groups whose order can be arranged in the current view (deck view in match view too)."""
index = self.stack.currentIndex()
if index in (0, 1):
return [self.my_sections, self.opp_sections]
return [self.toggles[2]]
def _fill_customize(self) -> None:
menu = self.customize_menu
menu.clear()
index = self.stack.currentIndex()
if index == 2:
self.toggles[2].fill_menu(menu)
else:
self.toggles[index].fill_menu(menu)
for tg in (self.my_sections, self.opp_sections):
sub = StayOpenMenu(f"{tg.title} sections", menu)
tg.fill_menu(sub)
menu.addMenu(sub)
menu.addSeparator()
arrange = menu.addAction("Arrange " + ("stats" if index == 2 else "deck sections") + "…")
arrange.triggered.connect(self._open_arrange)
def _open_arrange(self) -> None:
index = self.stack.currentIndex()
title = "Arrange turn stats" if index == 2 else "Arrange deck sections"
ArrangeDialog(self._arrangeable(), title, self).exec()
def show_view(self, index: int) -> None:
index = max(0, min(len(VIEWS) - 1, index))
self.customize.setEnabled(index != HISTORY)
# Move the shared deck view to wherever it is needed.
if index == 1:
self.match_view.deck_slot_layout.addWidget(self.deck_view)
else:
self.deck_page_layout.addWidget(self.deck_view)
self.stack.setCurrentIndex(index)
self.nav.button(index).setChecked(True)
self._settings.setValue("view", index)
self._force_refresh()
# -- menu actions ----------------------------------------------------------
def _choose_log(self, replay_rate: float | None) -> None:
start = str(self.follower.path.parent) if self.follower else str(Path.home())
@@ -138,7 +272,7 @@ class MainWindow(QMainWindow):
self.images.clear()
def _on_cache_size(self, size: int) -> None:
self.status_cache.setText(f"Image cache: {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
self.status_cache.setText(f"Image cache {size / 1_048_576:.1f} / {self.images.max_bytes / 1_048_576:.0f} MB")
# -- log -----------------------------------------------------------------
@@ -147,6 +281,7 @@ class MainWindow(QMainWindow):
self.follower.stop()
self.follower.wait(2000)
self._reset_tracker()
self.replaying = replay_rate is not None
self.follower = LogFollower(path, replay_rate)
self.follower.events.connect(self._on_events)
self.follower.status.connect(self.status_log.setText)
@@ -155,13 +290,56 @@ class MainWindow(QMainWindow):
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
self._rendered_version = -1
self.recorder.reset()
self._force_refresh()
def _on_events(self, events: list) -> None:
saved = False
for ev in events:
self.tracker.handle(ev)
saved |= self.recorder.observe(ev, self.tracker)
if saved:
self.history_view.set_games(self.history.games())
# -- history ---------------------------------------------------------------
def import_history(self, path: Path) -> None:
worker = HistoryImport(path, self.history, self.cards)
worker.done.connect(lambda n: n and self.history_view.set_games(self.history.games()))
worker.finished.connect(lambda: self._importers.remove(worker))
self._importers.append(worker)
worker.start()
def open_past_game(self, match_id: str, game_number: int) -> None:
summary = next((g for g in self.history.games()
if g.match_id == match_id and g.game_number == game_number), None)
events = self.history.events(match_id)
if summary is None or not events:
return
tracker = replay_game(events, game_number, self.cards)
when = fmt_when(summary.started_ms)
result = {"Won": "won", "Lost": "lost"}.get(summary.result, "")
text = (f"Viewing a past game: <b>vs {summary.opponent or '?'}</b> · {when}"
+ (f" · game {game_number} of 3" if summary.best_of == 3 else "")
+ (f" · you {result}" if result else ""))
self.past = (tracker, text)
self.past_label.setText(text)
self.past_bar.show()
self.turn_view.selected = None
self.show_view(0)
def close_past_game(self) -> None:
self.past = None
self.past_bar.hide()
self._force_refresh()
@property
def shown_tracker(self) -> MatchTracker:
return self.past[0] if self.past else self.tracker
def shutdown(self) -> None:
for worker in list(self._importers):
worker.wait(5000)
if self.follower:
self.follower.stop()
self.follower.wait(2000)
@@ -174,117 +352,69 @@ class MainWindow(QMainWindow):
# -- rendering -------------------------------------------------------------
def _rows(self, counts: Counter[int], note=None, dim: bool = False) -> list[Row]:
rows = []
for grp, n in counts.items():
if n <= 0:
continue
info = self.cards.get(grp)
rows.append(Row(info=info, count=n, note=note(grp, n) if note else "", dim=dim))
return rows
def now_ms(self) -> int:
"""Wall clock when following live; the newest log time when replaying or viewing the past."""
t = self.shown_tracker
if (self.replaying or self.past) and t.last_ms:
return t.last_ms
return int(time.time() * 1000)
def _force_refresh(self) -> None:
self._rendered.clear()
self.refresh()
def _tick(self) -> None:
if self.stack.currentIndex() == 1:
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
t = self.tracker
if t.version == self._rendered_version:
t = self.shown_tracker
index = self.stack.currentIndex()
key = (id(t), t.version)
if self._rendered.get(index) == key:
return
self._rendered_version = t.version
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)
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, g: GameState | None) -> None:
def _render_header(self, t: MatchTracker) -> None:
g = t.game
if g is None:
self.header.setText("Waiting for a match…")
self.warning.setVisible(not self.cards.available)
return
opp = t.opponent_name or "opponent"
parts = [f"<b>vs {opp}</b>"]
self.header.setText("<span style='color:#8b949c'>Waiting for a match…</span>")
else:
parts = [f"<b>vs {t.opponent_name or 'opponent'}</b>"]
if t.event_id:
parts.append(t.event_id.replace("_", " "))
parts.append(f"Game {g.game_number}")
if g.turn:
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}")
parts.append(f"Turn {g.turn}")
if not t.in_match and t.last_result:
parts.append(f"<b>Match {t.last_result.lower()}</b>")
self.header.setText(" · ".join(parts))
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 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 "")
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.warning.show()
else:
self.warning.setVisible(not self.cards.available)
def _render_idle(self, t: MatchTracker) -> None:
sections = []
if d := t.submitted_deck:
sections.append(Section("deck", f"{d.name or 'Last submitted deck'} ({sum(d.main.values())})",
self._rows(d.main)))
if d.sideboard:
sections.append(Section("side", f"Sideboard ({sum(d.sideboard.values())})",
self._rows(d.sideboard), collapsed_by_default=True))
self.mine.set_sections(sections)
self.opp.set_sections([])
def _render_mine(self, t: MatchTracker, g: GameState) -> None:
me = g.my_seat
lib_size = g.zone_size("ZoneType_Library", me)
remaining = t.my_library_remaining()
denom = lib_size or sum(remaining.values()) or 1
def pct(_grp: int, n: int) -> str:
return f"{100 * n / denom:.0f}%"
# Every card in the deck; ones with no copies left stay listed, greyed out.
lib_rows = [
Row(self.cards.get(grp), n, pct(grp, n) if n else "", dim=not n)
for grp in g.deck
for n in [remaining.get(grp, 0)]
]
sections = [
Section("library", f"Library ({lib_size})", lib_rows),
Section("hand", f"Hand ({g.zone_size('ZoneType_Hand', me)})", self._rows(g.cards_in("ZoneType_Hand", owner=me))),
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=me))),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', me)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=me))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=me))),
Section("sideboard", f"Sideboard ({sum(g.sideboard.values())})", self._rows(g.sideboard),
collapsed_by_default=True),
]
self.mine.set_sections([s for s in sections if s.rows or s.key in ("library", "hand")])
def _render_opp(self, t: MatchTracker, g: GameState) -> None:
opp = g.opp_seat
hand_size = g.zone_size("ZoneType_Hand", opp)
lib_size = g.zone_size("ZoneType_Library", opp)
known_hand = g.opp_known_in("ZoneType_Hand")
known_lib = g.opp_known_in("ZoneType_Library")
seen = t.opp_seen_match
sections = [
Section("battlefield", "Battlefield", self._rows(g.cards_in("ZoneType_Battlefield", controller=opp))),
Section("stack", "Stack", self._rows(g.cards_in("ZoneType_Stack", owner=opp))),
Section("hand", f"Hand ({hand_size}, {sum(known_hand.values())} known)", self._rows(known_hand)),
Section("graveyard", f"Graveyard ({g.zone_size('ZoneType_Graveyard', opp)})",
self._rows(g.cards_in("ZoneType_Graveyard", owner=opp))),
Section("exile", "Exile", self._rows(g.cards_in("ZoneType_Exile", owner=opp))),
Section("library", f"Library ({lib_size}, {sum(known_lib.values())} known)", self._rows(known_lib)),
Section("seen", f"Seen this match ({sum(seen.values())} cards)",
[r for r in self._rows(seen) if not r.info.is_token]),
]
self.opp.set_sections([s for s in sections if s.rows or s.key in ("battlefield", "hand", "seen")])
self.status_gap.setVisible(unaccounted > 0)
self.warning.setText("\n".join(messages))
self.warning.setVisible(bool(messages))
+287
View File
@@ -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 = " · <span style='color:%s'>active</span>" % 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" · <span style='color:{theme.MINT_TEXT if on_play else theme.MUTED}'>"
f"{'▶ on the play' if on_play else 'on the draw'}</span>")
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"<b>{v}</b> {self.cards.counter_name(k).lower()}"
for k, v in sorted(counters.items()))
or f"<span style='color:{theme.MUTED}'>No counters</span>")
self.counters.setVisible(t.on("counters"))
self.zones.setText(
f"Hand <b>{g.zone_size('ZoneType_Hand', seat)}</b> · "
f"Library <b>{g.zone_size('ZoneType_Library', seat)}</b> · "
f"Graveyard <b>{g.zone_size('ZoneType_Graveyard', seat)}</b> · "
# Exile is a shared zone, so count the seat's visible cards in it.
f"Exile <b>{sum(g.cards_in('ZoneType_Exile', owner=seat).values())}</b>"
)
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"<span style='color:{theme.MUTED}'>Land drop –</span>")
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 <span style='color:{color}; font-weight:bold'>{used}/{allowed}</span>")
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"<span style='font-size:{theme.FONT_PT * 2}pt; color:{color}; font-weight:bold'>"
f"⏱ {fmt_seconds(remaining)}</span>")
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"<br><b>{more} had more games on the draw</b>" if more else "<br>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)
+136
View File
@@ -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)
+98
View File
@@ -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)
+187
View File
@@ -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])
+31
View File
@@ -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
+67
View File
@@ -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() == []
+41
View File
@@ -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")
+214
View File
@@ -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)