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
+2599
-193
No files matched your search
@@ -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