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>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-19 09:54:08 -04:00
1 parent f16a67e042
commit d9ef771db8
4 files changed
+62 -10

No files matched your search

+30 -1
View File
@@ -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:
+5 -5
View File
@@ -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:
+6 -2
View File
@@ -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))
+21 -2
View File
@@ -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