From d9ef771db8e243fe187e18a311f8a82a747ff62d Mon Sep 17 00:00:00 2001 From: Aeth3r Date: Mon, 19 Oct 2026 09:54:08 -0400 Subject: [PATCH] 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 --- src/mtga_assistant/carddb.py | 31 ++++++++++++++++++++++++++++- src/mtga_assistant/gamestate.py | 10 +++++----- src/mtga_assistant/ui/match_view.py | 8 ++++++-- tests/test_gamestate.py | 23 +++++++++++++++++++-- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/mtga_assistant/carddb.py b/src/mtga_assistant/carddb.py index 42c8779..7d69182 100644 --- a/src/mtga_assistant/carddb.py +++ b/src/mtga_assistant/carddb.py @@ -6,7 +6,7 @@ import re import shutil import sqlite3 import sys -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import lru_cache from pathlib import Path @@ -117,6 +117,24 @@ def open_card_db(db: Path | None, copy_dir: Path, platform: str = sys.platform) return CardDB(db) +def command_object_info(cards: "CardDB", obj: dict) -> CardInfo: + """Display info for a non-card command-zone object (an emblem, boon, ...). + + These have their own small grpIds that aren't cards (e.g. 6 for a boon), so naming them by + grpId gives "Card #6". Use the object's own name if the log gives one, otherwise the card that + created it. The returned grp_id is the source card's, so hovering previews that card. + """ + kind = (obj.get("type") or "").removeprefix("GameObjectType_") or "Emblem" + source = obj.get("objectSourceGrpId") + source_info = cards.get(source) if source else None + name = cards.loc(obj["name"]) if obj.get("name") else None + if not name: + name = f"{source_info.name} ({kind.lower()})" if source_info else kind + # Without a source card there's nothing sensible to preview (grp_id 0 = no hover preview). + base = source_info or CardInfo(grp_id=0, name=name) + return replace(base, name=name, mana=(), types=(kind,), is_token=False) + + def unknown_card(grp_id: int) -> CardInfo: return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}") @@ -169,6 +187,17 @@ class CardDB: colors=tuple(c for c in WUBRG if c in color_set), ) + @lru_cache(maxsize=None) + def loc(self, loc_id: int) -> str | None: + """A localized (English) string by LocId, e.g. an emblem's name.""" + if self._conn is None: + return None + row = self._conn.execute( + "SELECT Loc FROM Localizations_enUS WHERE LocId = ? ORDER BY Formatted = 1 DESC LIMIT 1", + (loc_id,), + ).fetchone() + return _strip_tags(row[0]) if row and row[0] else None + @lru_cache(maxsize=None) def counter_name(self, counter_type: int) -> str: if self._conn is not None: diff --git a/src/mtga_assistant/gamestate.py b/src/mtga_assistant/gamestate.py index 8abf1cd..ea50946 100644 --- a/src/mtga_assistant/gamestate.py +++ b/src/mtga_assistant/gamestate.py @@ -295,17 +295,17 @@ class GameState: if seat := obj.get("ownerSeatId"): self.commander_casts[seat] += 1 - def emblems(self, seat: int) -> Counter[int]: - """Emblems and other non-card objects in the seat's command zone (commanders excluded).""" - out: Counter[int] = Counter() + def emblems(self, seat: int) -> list[dict[str, Any]]: + """Emblems, boons and other non-card objects in the seat's command zone (commanders excluded).""" + out = [] for z in self.zones.values(): if z.type != "ZoneType_Command": continue for iid in z.ids: obj = self.objects.get(iid) - if obj and obj.get("grpId") and obj.get("type") != "GameObjectType_Card" \ + if obj and obj.get("type") != "GameObjectType_Card" \ and obj.get("ownerSeatId", obj.get("controllerSeatId")) == seat: - out[obj["grpId"]] += 1 + out.append(obj) return out def _land_rules(self) -> dict: diff --git a/src/mtga_assistant/ui/match_view.py b/src/mtga_assistant/ui/match_view.py index eb4c080..4d2e144 100644 --- a/src/mtga_assistant/ui/match_view.py +++ b/src/mtga_assistant/ui/match_view.py @@ -15,7 +15,7 @@ from PySide6.QtWidgets import ( QWidget, ) -from ..carddb import CardDB +from ..carddb import CardDB, command_object_info from ..gamestate import GameState, MatchTracker from . import theme from .card_list import CardList, Row, Section @@ -164,8 +164,12 @@ class PlayerCard(QFrame): self.commander.setVisible(t.on("commander") and bool(status)) emblems = g.emblems(seat) + counts: dict[str, list] = {} # name -> [info, count] + for obj in emblems: + info = command_object_info(self.cards, obj) + counts.setdefault(info.name, [info, 0])[1] += 1 self.emblems.set_sections([Section("emblems", "Emblems", [ - Row(self.cards.get(grp), n) for grp, n in emblems.items() + Row(info, n) for info, n in counts.values() ])] if emblems else []) self.emblems.setVisible(t.on("emblems") and bool(emblems)) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 64567b2..bfaf041 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -211,8 +211,8 @@ def test_commanders_are_not_emblems(): 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 [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. @@ -247,3 +247,22 @@ def test_known_cards_never_exceed_zone_size(): 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