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
@@ -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