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 [o["grpId"] for o in g.emblems(OPP)] == [7777] 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 def test_revealed_card_already_tracked_is_not_double_counted(): t = MatchTracker() start_game(t) # Their card is bounced back to hand (tracked), and a reveal shows the same card. 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)])])) t.handle(GreMessages([gsm(3, annotations=[id_changed(210, 220)], zones=[ zone(OPP_HAND, "Hand", [220], OPP), zone(BATTLEFIELD, "Battlefield", []), ], objects=[card(400, 5005, OPP_HAND, otype="RevealedCard")], deleted=[210])])) assert t.game.opp_known_in("ZoneType_Hand") == {5005: 1} def test_known_cards_never_exceed_zone_size(): t = MatchTracker() start_game(t) # Two reveals of different cards while their hand only has one card left. t.handle(GreMessages([gsm(2, zones=[zone(OPP_HAND, "Hand", [201], OPP)], objects=[ card(400, 7007, OPP_HAND, otype="RevealedCard"), card(401, 7008, OPP_HAND, otype="RevealedCard")])])) assert sum(t.game.opp_known_in("ZoneType_Hand").values()) == 1 def test_command_zone_objects_are_named_from_log_or_source_card(): from mtga_assistant.carddb import command_object_info class Cards: def get(self, grp): return CardInfo(grp_id=grp, name={97038: "Swiftspear's Teachings", 87496: "The Ring"}.get(grp, f"Card #{grp}")) def loc(self, loc_id): return {732510: "The Ring"}.get(loc_id) boon = {"grpId": 6, "type": "GameObjectType_Boon", "objectSourceGrpId": 97038} emblem = {"grpId": 2, "type": "GameObjectType_Emblem", "objectSourceGrpId": 87496, "name": 732510} bare = {"grpId": 3, "type": "GameObjectType_Emblem"} b, e, x = (command_object_info(Cards(), o) for o in (boon, emblem, bare)) assert (b.name, b.types, b.grp_id) == ("Swiftspear's Teachings (boon)", ("Boon",), 97038) # hover shows the source card assert (e.name, e.types, e.grp_id) == ("The Ring", ("Emblem",), 87496) assert x.name == "Emblem" and x.grp_id == 0 # no source card: nothing to preview