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