Add match, turn and history views with a dark theme
- Top bar switching between Deck, Match, Turn and History views - Match view: life, player counters, commanders, emblems, zone counts, turn timer and timeouts, land drops used/allowed, play/draw and Bo3 draw counts, with the deck view below - Turn view: per-player and total stats per turn with history, plus whole-game totals and land drops - History: every finished game (colours, winner, who was on the play); click to reopen it in the other views; imports Player-prev.log - Customize menu to show/hide items and an Arrange dialog to reorder deck sections and turn stats, remembered across sessions - Static dark theme with mint accent, rounded corners, larger text - Commanders tracked separately from emblems, with location and tax - Quieter, non-sticky warning for cards lost to MTGA log gaps - Correct Scryfall images for tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dc2ce0da8
commit
5b3c8c8e63
22 files changed
+2589
-183
No files matched your search
@@ -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,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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() == []
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user