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 shutil
import sqlite3 import sqlite3
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from functools import lru_cache from functools import lru_cache
from pathlib import Path 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) 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: def unknown_card(grp_id: int) -> CardInfo:
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}") 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), 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) @lru_cache(maxsize=None)
def counter_name(self, counter_type: int) -> str: def counter_name(self, counter_type: int) -> str:
if self._conn is not None: if self._conn is not None:
+5 -5
View File
@@ -295,17 +295,17 @@ class GameState:
if seat := obj.get("ownerSeatId"): if seat := obj.get("ownerSeatId"):
self.commander_casts[seat] += 1 self.commander_casts[seat] += 1
def emblems(self, seat: int) -> Counter[int]: def emblems(self, seat: int) -> list[dict[str, Any]]:
"""Emblems and other non-card objects in the seat's command zone (commanders excluded).""" """Emblems, boons and other non-card objects in the seat's command zone (commanders excluded)."""
out: Counter[int] = Counter() out = []
for z in self.zones.values(): for z in self.zones.values():
if z.type != "ZoneType_Command": if z.type != "ZoneType_Command":
continue continue
for iid in z.ids: for iid in z.ids:
obj = self.objects.get(iid) 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: and obj.get("ownerSeatId", obj.get("controllerSeatId")) == seat:
out[obj["grpId"]] += 1 out.append(obj)
return out return out
def _land_rules(self) -> dict: def _land_rules(self) -> dict:
+6 -2
View File
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ..carddb import CardDB from ..carddb import CardDB, command_object_info
from ..gamestate import GameState, MatchTracker from ..gamestate import GameState, MatchTracker
from . import theme from . import theme
from .card_list import CardList, Row, Section from .card_list import CardList, Row, Section
@@ -164,8 +164,12 @@ class PlayerCard(QFrame):
self.commander.setVisible(t.on("commander") and bool(status)) self.commander.setVisible(t.on("commander") and bool(status))
emblems = g.emblems(seat) 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", [ 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 []) ])] if emblems else [])
self.emblems.setVisible(t.on("emblems") and bool(emblems)) 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 g = t.game
assert g.commander_status(ME) == [(8000, "command zone")] assert g.commander_status(ME) == [(8000, "command zone")]
assert g.commander_status(OPP) == [(9000, "command zone")] assert g.commander_status(OPP) == [(9000, "command zone")]
assert g.emblems(OPP) == {7777: 1} assert [o["grpId"] for o in g.emblems(OPP)] == [7777]
assert g.emblems(ME) == {} assert g.emblems(ME) == []
assert sum(t.my_library_remaining().values()) == 3 # the commander isn't part of the library 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. # 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=[ 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")])])) 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 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