- 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>
228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
from pathlib import Path
|
|
|
|
from mtga_assistant.carddb import CardInfo
|
|
from mtga_assistant.gamestate import MatchTracker
|
|
from mtga_assistant.logparse import GreMessages, LogParser
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
|
|
|
|
ME, OPP = 1, 2
|
|
# Zone ids as MTGA assigns them for a two-player game.
|
|
BATTLEFIELD, EXILE, LIMBO = 28, 29, 30
|
|
MY_HAND, MY_LIB, MY_GY = 31, 32, 33
|
|
OPP_HAND, OPP_LIB, OPP_GY = 35, 36, 37
|
|
|
|
|
|
def replay(path: Path, tracker: MatchTracker, check=None) -> None:
|
|
p = LogParser()
|
|
with open(path, encoding="utf-8") as f:
|
|
for line in f:
|
|
for ev in p.feed(line):
|
|
tracker.handle(ev)
|
|
if check and isinstance(ev, GreMessages):
|
|
check(tracker)
|
|
|
|
|
|
def test_fixture_match_lifecycle():
|
|
t = MatchTracker()
|
|
replay(FIXTURE, t)
|
|
assert t.my_seat == ME
|
|
assert t.opponent_name == "Opponent Player"
|
|
assert t.game is not None and t.game.game_number == 2
|
|
assert t.game_results == ["Won", "Won"]
|
|
assert t.last_result == "Won"
|
|
assert not t.in_match
|
|
assert t.deck_name == "Cruel Tutelage"
|
|
|
|
|
|
def test_fixture_library_count_matches_library_zone():
|
|
checked = 0
|
|
|
|
def check(t: MatchTracker) -> None:
|
|
nonlocal checked
|
|
g = t.game
|
|
if g is None or not g.zones or g.desynced:
|
|
return
|
|
assert sum(t.my_library_remaining().values()) == g.zone_size("ZoneType_Library", ME)
|
|
checked += 1
|
|
|
|
replay(FIXTURE, MatchTracker(), check)
|
|
assert checked >= 15
|
|
|
|
|
|
# -- synthetic GRE messages for opponent knowledge ------------------------------
|
|
|
|
|
|
def gsm(state_id: int, *, zones=(), objects=(), annotations=(), deleted=(), full=False) -> dict:
|
|
msg = {
|
|
"type": "GameStateType_Full" if full else "GameStateType_Diff",
|
|
"gameStateId": state_id,
|
|
"zones": list(zones),
|
|
"gameObjects": list(objects),
|
|
"annotations": list(annotations),
|
|
"diffDeletedInstanceIds": list(deleted),
|
|
}
|
|
if not full:
|
|
msg["prevGameStateId"] = state_id - 1
|
|
return {"type": "GREMessageType_GameStateMessage", "gameStateMessage": msg}
|
|
|
|
|
|
def zone(zid: int, ztype: str, ids, owner=None) -> dict:
|
|
z = {"zoneId": zid, "type": f"ZoneType_{ztype}", "objectInstanceIds": list(ids)}
|
|
if owner is not None:
|
|
z["ownerSeatId"] = owner
|
|
return z
|
|
|
|
|
|
def card(iid: int, grp: int, zid: int, owner=OPP, otype="Card") -> dict:
|
|
return {"instanceId": iid, "grpId": grp, "type": f"GameObjectType_{otype}", "zoneId": zid,
|
|
"ownerSeatId": owner, "controllerSeatId": owner}
|
|
|
|
|
|
def id_changed(orig: int, new: int) -> dict:
|
|
return {"type": ["AnnotationType_ObjectIdChanged"], "details": [
|
|
{"key": "orig_id", "valueInt32": [orig]}, {"key": "new_id", "valueInt32": [new]}]}
|
|
|
|
|
|
def shuffle(old: list[int], new: list[int]) -> dict:
|
|
return {"type": ["AnnotationType_Shuffle"], "details": [
|
|
{"key": "OldIds", "valueInt32": old}, {"key": "NewIds", "valueInt32": new}]}
|
|
|
|
|
|
def start_game(t: MatchTracker, deck=(1001,) * 4) -> None:
|
|
t.handle(GreMessages([{
|
|
"type": "GREMessageType_ConnectResp", "systemSeatIds": [ME],
|
|
"connectResp": {"deckMessage": {"deckCards": list(deck), "sideboardCards": []}},
|
|
}]))
|
|
t.handle(GreMessages([gsm(1, full=True, zones=[
|
|
zone(BATTLEFIELD, "Battlefield", []), zone(LIMBO, "Limbo", []),
|
|
zone(MY_HAND, "Hand", [], ME), zone(MY_LIB, "Library", range(100, 100 + len(deck)), ME),
|
|
zone(OPP_HAND, "Hand", [200, 201], OPP), zone(OPP_LIB, "Library", [300, 301, 302], OPP),
|
|
zone(OPP_GY, "Graveyard", [], OPP),
|
|
])]))
|
|
|
|
|
|
def test_bounced_card_is_known_in_opponent_hand():
|
|
t = MatchTracker()
|
|
start_game(t)
|
|
# Opponent casts card 200 -> it resolves onto the battlefield as instance 210.
|
|
t.handle(GreMessages([gsm(2, annotations=[id_changed(200, 210)], zones=[
|
|
zone(OPP_HAND, "Hand", [201], OPP), zone(BATTLEFIELD, "Battlefield", [210]),
|
|
], objects=[card(210, 5005, BATTLEFIELD)])]))
|
|
g = t.game
|
|
assert g.cards_in("ZoneType_Battlefield", controller=OPP) == {5005: 1}
|
|
|
|
# It gets bounced: new id 220, back in the hidden hand, no object sent for it.
|
|
t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[
|
|
zone(OPP_HAND, "Hand", [201, 220], OPP), zone(BATTLEFIELD, "Battlefield", []),
|
|
], deleted=[210])]))
|
|
assert g.opp_known_in("ZoneType_Hand") == {5005: 1}
|
|
assert not g.cards_in("ZoneType_Battlefield", controller=OPP)
|
|
assert t.opp_seen_match == {5005: 1} # same physical card, counted once
|
|
|
|
|
|
def test_known_card_survives_library_shuffle():
|
|
t = MatchTracker()
|
|
start_game(t)
|
|
g = t.game
|
|
# A card is revealed on top of their library (e.g. by a "reveal the top card" effect).
|
|
t.handle(GreMessages([gsm(2, objects=[card(300, 6006, OPP_LIB)])]))
|
|
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
|
|
|
|
t.handle(GreMessages([gsm(3, annotations=[shuffle([300, 301, 302], [310, 311, 312])],
|
|
zones=[zone(OPP_LIB, "Library", [310, 311, 312], OPP)], deleted=[300])]))
|
|
assert g.opp_known_in("ZoneType_Library") == {6006: 1}
|
|
|
|
# Later it is drawn and played: no longer "known in library".
|
|
t.handle(GreMessages([gsm(4, zones=[zone(OPP_LIB, "Library", [310, 311], OPP),
|
|
zone(BATTLEFIELD, "Battlefield", [320])],
|
|
objects=[card(320, 6006, BATTLEFIELD)])]))
|
|
assert g.opp_known_in("ZoneType_Library") == {}
|
|
|
|
|
|
def test_revealed_card_in_hand():
|
|
t = MatchTracker()
|
|
start_game(t)
|
|
t.handle(GreMessages([gsm(2, objects=[card(400, 7007, OPP_HAND, otype="RevealedCard")])]))
|
|
assert t.game.opp_known_in("ZoneType_Hand") == {7007: 1}
|
|
assert t.opp_seen_match == {7007: 1}
|
|
|
|
|
|
def test_tokens_do_not_count_as_my_cards():
|
|
t = MatchTracker()
|
|
start_game(t, deck=(1001,) * 4)
|
|
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
|
|
zone(MY_LIB, "Library", [101, 102, 103], ME), zone(BATTLEFIELD, "Battlefield", [150, 151]),
|
|
], objects=[card(150, 1001, BATTLEFIELD, owner=ME), card(151, 9999, BATTLEFIELD, owner=ME, otype="Token")])]))
|
|
assert t.my_library_remaining() == {1001: 3}
|
|
|
|
|
|
class FakeCards:
|
|
def __init__(self, base: dict[int, int]):
|
|
self.base = base
|
|
|
|
def get(self, grp):
|
|
return CardInfo(grp_id=grp, name=str(grp))
|
|
|
|
def base_grp(self, grp):
|
|
return self.base.get(grp, grp)
|
|
|
|
|
|
def test_transformed_card_maps_back_to_deck_entry():
|
|
t = MatchTracker(card_lookup=FakeCards({1002: 1001})) # 1002 is 1001's back face
|
|
start_game(t, deck=(1001,) * 2)
|
|
t.handle(GreMessages([gsm(2, annotations=[id_changed(100, 150)], zones=[
|
|
zone(MY_LIB, "Library", [101], ME), zone(BATTLEFIELD, "Battlefield", [150]),
|
|
], objects=[card(150, 1002, BATTLEFIELD, owner=ME)])]))
|
|
assert t.my_library_remaining() == {1001: 1}
|
|
|
|
|
|
def test_gap_in_state_ids_marks_desync():
|
|
t = MatchTracker()
|
|
start_game(t)
|
|
t.handle(GreMessages([gsm(2)]))
|
|
assert not t.game.desynced
|
|
t.handle(GreMessages([gsm(5)])) # prevGameStateId 4 never seen
|
|
assert t.game.desynced
|
|
|
|
|
|
def test_object_logged_before_its_id_change_is_one_card():
|
|
t = MatchTracker()
|
|
start_game(t)
|
|
t.handle(GreMessages([gsm(2, objects=[card(210, 5005, BATTLEFIELD)])]))
|
|
# The new object shows up one message before the annotation linking it to its old id.
|
|
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
|