Add match, turn and history views with a dark theme

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

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

No files matched your search

+31
View File
@@ -194,3 +194,34 @@ def test_object_logged_before_its_id_change_is_one_card():
t.handle(GreMessages([gsm(3, objects=[card(220, 5005, BATTLEFIELD)])]))
t.handle(GreMessages([gsm(4, annotations=[id_changed(210, 220)])]))
assert t.opp_seen_match == {5005: 1}
def test_commanders_are_not_emblems():
COMMAND, STACK = 26, 27
t = MatchTracker()
t.handle(GreMessages([{
"type": "GREMessageType_ConnectResp", "systemSeatIds": [ME],
"connectResp": {"deckMessage": {"deckCards": [1001] * 3, "commanderCards": [8000]}},
}]))
t.handle(GreMessages([gsm(1, full=True, zones=[
zone(COMMAND, "Command", [50, 51, 52]), zone(STACK, "Stack", []), zone(BATTLEFIELD, "Battlefield", []),
zone(MY_LIB, "Library", [100, 101, 102], ME), zone(OPP_HAND, "Hand", [], OPP),
], objects=[card(50, 8000, COMMAND, ME), card(51, 9000, COMMAND, OPP),
card(52, 7777, COMMAND, OPP, otype="Emblem")])]))
g = t.game
assert g.commander_status(ME) == [(8000, "command zone")]
assert g.commander_status(OPP) == [(9000, "command zone")]
assert g.emblems(OPP) == {7777: 1}
assert g.emblems(ME) == {}
assert sum(t.my_library_remaining().values()) == 3 # the commander isn't part of the library
# Opponent casts their commander from the command zone: it's on the stack, tax goes up.
cast = {"type": ["AnnotationType_ZoneTransfer"], "affectedIds": [60], "details": [
{"key": "zone_src", "valueInt32": [COMMAND]}, {"key": "zone_dest", "valueInt32": [STACK]},
{"key": "category", "valueString": ["CastSpell"]}]}
t.handle(GreMessages([gsm(2, annotations=[id_changed(51, 60), cast], zones=[
zone(COMMAND, "Command", [50, 52]), zone(STACK, "Stack", [60]),
], objects=[card(60, 9000, STACK, OPP)], deleted=[51])]))
assert g.commander_status(OPP) == [(9000, "stack")]
assert g.commander_tax(OPP) == 2
assert g.commander_tax(ME) == 0
+67
View File
@@ -0,0 +1,67 @@
from mtga_assistant.history import HistoryRecorder, HistoryStore, decode, encode, import_log, replay_game
from mtga_assistant.logparse import LogParser
from test_gamestate import FIXTURE, ME
def parse(path):
p = LogParser()
with open(path, encoding="utf-8") as f:
return [ev for line in f for ev in p.feed(line)]
def test_events_round_trip():
for ev in parse(FIXTURE):
enc = encode(ev)
if enc is None:
continue
back = decode(enc)
assert type(back) is type(ev)
def test_import_records_each_game_once(tmp_path):
store = HistoryStore(tmp_path / "h.db")
assert import_log(FIXTURE, store, None) == 1
assert import_log(FIXTURE, store, None) == 0 # already recorded
games = store.games()
assert [g.game_number for g in games] == [2, 1] # newest first
g1 = games[1]
assert g1.opponent == "Opponent Player"
assert g1.result == "Won" and games[0].result == "Won"
assert g1.best_of == 3
assert g1.on_play == "me"
assert g1.deck_name == "Cruel Tutelage"
assert g1.replayable
def test_replay_stops_at_the_requested_game(tmp_path):
store = HistoryStore(tmp_path / "h.db")
import_log(FIXTURE, store, None)
events = store.events(store.games()[0].match_id)
t1 = replay_game(events, 1, None)
assert t1.game.game_number == 1
assert t1.game.on_play == ME
assert t1.game_results == ["Won"]
t2 = replay_game(events, 2, None)
assert t2.game.game_number == 2
assert t2.game_results == ["Won", "Won"]
assert t2.games_on_draw() == {2: 1}
def test_events_pruned_beyond_cap_but_summaries_kept(tmp_path):
store = HistoryStore(tmp_path / "h.db", max_events_mb=0)
import_log(FIXTURE, store, None)
games = store.games()
assert len(games) == 2 and not any(g.replayable for g in games)
assert store.events(games[0].match_id) == []
def test_recorder_ignores_unfinished_match(tmp_path):
from mtga_assistant.gamestate import MatchTracker
store = HistoryStore(tmp_path / "h.db")
rec, t = HistoryRecorder(store), MatchTracker()
events = parse(FIXTURE)
for ev in events[:-3]: # stop before the match completes
t.handle(ev)
rec.observe(ev, t)
assert store.games() == []
+41
View File
@@ -0,0 +1,41 @@
import pytest
from PySide6.QtCore import QSettings
from mtga_assistant.ui.toggles import Toggles
ITEMS = [("a", "A"), ("b", "B"), ("c", "C")]
@pytest.fixture(autouse=True)
def isolated_settings(tmp_path):
QSettings.setPath(QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path))
yield
def test_default_order_and_visibility():
t = Toggles("g1", ITEMS, defaults_off={"b"})
assert t.order() == ["a", "b", "c"]
assert not t.on("b") and t.on("a")
def test_order_and_visibility_persist():
t = Toggles("g2", ITEMS)
t.apply(["c", "a", "b"], {"a": False})
again = Toggles("g2", ITEMS)
assert again.order() == ["c", "a", "b"]
assert not again.on("a") and again.on("c")
assert [k for k, _ in again.ordered_items()] == ["c", "a", "b"]
def test_new_items_slot_in_after_their_default_neighbour():
Toggles("g3", ITEMS).apply(["c", "a", "b"], {})
grown = Toggles("g3", [("a", "A"), ("new", "New"), ("b", "B"), ("c", "C")])
assert grown.order() == ["c", "a", "new", "b"]
def test_unknown_saved_keys_are_dropped_and_reset_restores_defaults():
t = Toggles("g4", ITEMS)
t.apply(["gone", "b", "a", "c"], {"b": False})
assert t.order() == ["b", "a", "c"]
t.reset()
assert t.order() == ["a", "b", "c"] and t.on("b")
+214
View File
@@ -0,0 +1,214 @@
from mtga_assistant.gamestate import MatchTracker
from mtga_assistant.logparse import GreMessages
from mtga_assistant.turnstats import COUNTER_ENERGY, COUNTER_POISON
from test_gamestate import (
BATTLEFIELD, FIXTURE, ME, MY_HAND, MY_LIB, OPP, OPP_GY, OPP_HAND, OPP_LIB, card, gsm, id_changed, replay,
start_game, zone,
)
STACK = 27
def transfer(iid: int, src: int, dest: int, category: str) -> dict:
return {"type": ["AnnotationType_ZoneTransfer"], "affectedIds": [iid], "details": [
{"key": "zone_src", "valueInt32": [src]}, {"key": "zone_dest", "valueInt32": [dest]},
{"key": "category", "valueString": [category]}]}
def life(seat: int, delta: int) -> dict:
return {"type": ["AnnotationType_ModifiedLife"], "affectedIds": [seat],
"details": [{"key": "life", "valueInt32": [delta]}]}
def damage(seat: int, n: int) -> dict:
return {"type": ["AnnotationType_DamageDealt"], "affectedIds": [seat],
"details": [{"key": "damage", "valueInt32": [n]}]}
def counter_added(seat: int, ctype: int, n: int, removed: bool = False) -> dict:
kind = "AnnotationType_CounterRemoved" if removed else "AnnotationType_CounterAdded"
return {"type": [kind], "affectedIds": [seat], "details": [
{"key": "counter_type", "valueInt32": [ctype]}, {"key": "transaction_amount", "valueInt32": [n]}]}
def with_turn(msg: dict, turn: int, active: int) -> dict:
msg["gameStateMessage"]["turnInfo"] = {"turnNumber": turn, "activePlayer": active, "phase": "Phase_Main1"}
return msg
def creature(iid: int, grp: int, zid: int, owner: int) -> dict:
obj = card(iid, grp, zid, owner)
obj["cardTypes"] = ["CardType_Creature"]
return obj
def test_turn_events_are_attributed_per_player_and_turn():
t = MatchTracker()
start_game(t)
# Turn 1 (mine): draw, cast a creature that resolves.
t.handle(GreMessages([with_turn(gsm(2, annotations=[
id_changed(100, 150), transfer(150, MY_LIB, MY_HAND, "Draw"),
], zones=[zone(MY_LIB, "Library", [101, 102, 103], ME), zone(MY_HAND, "Hand", [150], ME)],
objects=[card(150, 1001, MY_HAND, ME)]), 1, ME)]))
t.handle(GreMessages([gsm(3, annotations=[
id_changed(150, 151), transfer(151, MY_HAND, STACK, "CastSpell"),
], zones=[zone(MY_HAND, "Hand", [], ME), zone(STACK, "Stack", [151])],
objects=[creature(151, 1001, STACK, ME)])]))
# Turn 2 (theirs): they draw (hidden), play a land, lose 3 life and take 2 damage.
t.handle(GreMessages([with_turn(gsm(4, annotations=[
id_changed(300, 310), transfer(310, OPP_LIB, OPP_HAND, "Draw"),
id_changed(200, 210), transfer(210, OPP_HAND, BATTLEFIELD, "PlayLand"),
life(OPP, -3), damage(OPP, 2),
], zones=[zone(OPP_LIB, "Library", [301, 302], OPP), zone(OPP_HAND, "Hand", [201, 310], OPP),
zone(BATTLEFIELD, "Battlefield", [210])],
objects=[card(210, 5005, BATTLEFIELD)]), 2, OPP)]))
# Their creature dies on turn 2 as well.
t.handle(GreMessages([gsm(5, annotations=[
id_changed(210, 220), transfer(220, BATTLEFIELD, OPP_GY, "Destroy"), life(ME, 4),
], zones=[zone(BATTLEFIELD, "Battlefield", []), zone(OPP_GY, "Graveyard", [220], OPP)],
objects=[creature(220, 5005, OPP_GY, OPP)])]))
log = t.game.turns
assert log.turn(1)["drawn"] == {ME: 1}
assert log.turn(1)["cast"] == {ME: 1}
t2 = log.turn(2)
assert t2["drawn"] == {OPP: 1}
assert t2["lands"] == {OPP: 1}
assert t2["life_lost"] == {OPP: 3}
assert t2["damage_taken"] == {OPP: 2}
assert t2["creatures_died"] == {OPP: 1}
assert t2["life_gained"] == {ME: 4}
assert log.active == {1: ME, 2: OPP}
totals = log.game_totals()
assert totals["drawn"] == {ME: 1, OPP: 1}
assert log.turn_numbers() == [1, 2]
def test_player_counters_and_counter_stats():
t = MatchTracker()
start_game(t)
t.handle(GreMessages([with_turn(gsm(2, annotations=[
counter_added(OPP, COUNTER_POISON, 3), counter_added(ME, COUNTER_ENERGY, 2),
]), 3, ME)]))
t.handle(GreMessages([gsm(3, annotations=[counter_added(ME, COUNTER_ENERGY, 1, removed=True)])]))
g = t.game
assert g.player_counters[OPP][COUNTER_POISON] == 3
assert g.player_counters[ME][COUNTER_ENERGY] == 1
stats = g.turns.turn(3)
assert stats["poison_gained"] == {OPP: 3}
assert stats["energy_gained"] == {ME: 2}
assert stats["energy_spent"] == {ME: 1}
def test_turn_timer_counts_down_from_message_time():
t = MatchTracker()
start_game(t)
msg = gsm(2)
msg["gameStateMessage"]["players"] = [{"systemSeatNumber": ME, "timerIds": [3]},
{"systemSeatNumber": OPP, "timerIds": [9]}]
msg["gameStateMessage"]["timers"] = [{"timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 60,
"elapsedMs": 10_000, "running": True, "warningThresholdSec": 30}]
t.handle(GreMessages([msg], timestamp_ms=1_000_000))
g = t.game
assert g.turn_timer(ME, 1_000_000) == (50.0, 30.0, True)
assert g.turn_timer(ME, 1_005_000) == (45.0, 30.0, True)
assert g.turn_timer(OPP, 1_005_000) is None
# A stopped timer reports its remaining time without counting down.
msg = gsm(3)
msg["gameStateMessage"]["timers"] = [{"timerId": 3, "type": "TimerType_ActivePlayer", "durationSec": 61,
"warningThresholdSec": 30}]
t.handle(GreMessages([with_turn(msg, 1, ME)], timestamp_ms=1_010_000))
assert g.turn_timer(ME, 1_090_000) == (61.0, 30.0, False)
assert g.started_ms == 1_000_000
def test_fixture_draws_match_draw_transfers():
t = MatchTracker()
replay(FIXTURE, t)
g = t.game
drawn = g.turns.game_totals().get("drawn", {})
# Game 2 of the fixture: the player on the draw draws every turn they take.
my_turns = sum(1 for seat in g.turns.active.values() if seat == ME)
assert drawn.get(ME, 0) >= my_turns - 1
# -- land drops ------------------------------------------------------------------
from mtga_assistant.carddb import CardInfo, LandRule # noqa: E402
STATIC_EXTRA, EACH_PLAYER, THIS_TURN = 13760, 1169, 6426
EXPLORE_GRP = 4242 # a sorcery whose ability grants one extra land this turn
class LandCards:
def get(self, grp):
return CardInfo(grp_id=grp, name=str(grp))
def base_grp(self, grp):
return grp
def land_rules(self):
return {STATIC_EXTRA: LandRule(1, False, False), EACH_PLAYER: LandRule(1, False, True),
THIS_TURN: LandRule(1, True, False)}
def ability_ids(self, grp):
return (THIS_TURN,) if grp == EXPLORE_GRP else ()
def with_abilities(obj: dict, *ability_ids: int) -> dict:
obj["uniqueAbilities"] = [{"id": i, "grpId": a} for i, a in enumerate(ability_ids)]
return obj
def resolved(affector: int, grp: int) -> dict:
return {"type": ["AnnotationType_ResolutionComplete"], "affectorId": affector, "affectedIds": [affector],
"details": [{"key": "grpid", "valueInt32": [grp]}]}
def play_land(state_id: int, old: int, new: int, owner: int, hand: int) -> dict:
return gsm(state_id, annotations=[id_changed(old, new), transfer(new, hand, BATTLEFIELD, "PlayLand")],
objects=[card(new, 1001, BATTLEFIELD, owner)])
def test_land_drop_basic_turn():
t = MatchTracker(card_lookup=LandCards())
start_game(t)
t.handle(GreMessages([with_turn(gsm(2), 1, ME)]))
g = t.game
assert g.land_drop(ME) == (0, 1)
assert g.land_drop(OPP) is None # not their turn
t.handle(GreMessages([play_land(3, 100, 150, ME, MY_HAND)]))
assert g.land_drop(ME) == (1, 1)
assert g.turns.land_drop(1) == (1, 1)
def test_static_and_one_shot_extra_land_plays():
t = MatchTracker(card_lookup=LandCards())
start_game(t)
# Turn 1: I control a permanent with "play an additional land on each of your turns".
t.handle(GreMessages([with_turn(gsm(2, zones=[zone(BATTLEFIELD, "Battlefield", [500])],
objects=[with_abilities(card(500, 9001, BATTLEFIELD, ME), STATIC_EXTRA)]),
1, ME)]))
g = t.game
assert g.land_drop(ME) == (0, 2)
# Then I resolve a spell granting an additional land this turn.
t.handle(GreMessages([gsm(3, annotations=[resolved(501, EXPLORE_GRP)],
objects=[card(501, EXPLORE_GRP, 27, ME)])]))
assert g.land_drop(ME) == (0, 3)
t.handle(GreMessages([play_land(4, 100, 150, ME, MY_HAND)]))
assert g.land_drop(ME) == (1, 3)
# Turn 2 (theirs): my static doesn't help them; the one-shot is gone.
t.handle(GreMessages([with_turn(gsm(5), 2, OPP)]))
assert g.land_drop(OPP) == (0, 1)
# An "each player" effect on the battlefield helps them too.
t.handle(GreMessages([gsm(6, zones=[zone(BATTLEFIELD, "Battlefield", [500, 502])],
objects=[with_abilities(card(502, 9002, BATTLEFIELD, ME), EACH_PLAYER)])]))
assert g.land_drop(OPP) == (0, 2)
# History keeps what each turn allowed.
assert g.turns.land_drop(1) == (1, 3)
assert g.turns.land_drop(2) == (0, 2)
assert g.turns.land_drops_total(ME) == (1, 3)