Files
mtga-assistant/tests/test_gamestate.py
T
Aeth3randClaude Opus 5.5 d9ef771db8 Name emblems and boons instead of showing "Card #N"
Command-zone objects such as boons have small grpIds that aren't cards.
Name them from the log's name id (e.g. "The Ring") or the card that
created them ("Swiftspear's Teachings (boon)"), and preview the source
card on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 -04:00

269 lines
11 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 [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