diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py
index 78a8c9c..510c2e8 100644
--- a/src/apps/epic/models.py
+++ b/src/apps/epic/models.py
@@ -234,6 +234,22 @@ class DeckVariant(models.Model):
(PLAYING, "Playing card"),
]
+ # Per-family translation tables: canonical SUIT enum (Earthman vocab) →
+ # family-authentic display slug used in image filenames + UI labels.
+ # See [[reference-card-image-naming-convention]] v2.
+ _SUIT_SLUG_BY_FAMILY = {
+ EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"},
+ ITALIAN: {"BRANDS": "batons", "CROWNS": "coins", "GRAILS": "cups", "BLADES": "swords"},
+ ENGLISH: {"BRANDS": "wands", "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"},
+ PLAYING: {"BRANDS": "clubs", "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"},
+ }
+ _TRUMP_CATEGORY_BY_FAMILY = {
+ EARTHMAN: "trumps",
+ ITALIAN: "trumps",
+ ENGLISH: "majors",
+ PLAYING: None, # 52-card decks: no trump category (jokers handled separately)
+ }
+
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
card_count = models.IntegerField()
@@ -243,6 +259,20 @@ class DeckVariant(models.Model):
has_card_images = models.BooleanField(default=True)
is_polarized = models.BooleanField(default=False)
+ def suit_slug(self, canonical_suit):
+ """Map canonical SUIT enum → family-authentic filename slug.
+ e.g. ('italian', 'BRANDS') → 'batons'."""
+ return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit]
+
+ def suit_display(self, canonical_suit):
+ """User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'."""
+ return self.suit_slug(canonical_suit).capitalize()
+
+ @property
+ def trump_category(self):
+ """Filename-slug category for trump cards in this family."""
+ return self._TRUMP_CATEGORY_BY_FAMILY[self.family]
+
@property
def short_key(self):
"""First dash-separated word of slug — used as an HTML id component."""
@@ -452,6 +482,35 @@ class TarotCard(models.Model):
self.BLADES: 'fa-gun',
}.get(self.suit, '')
+ # Tarot-family courts: rank 11=page, 12=knight, 13=queen, 14=king. Playing
+ # family (3 courts: jack/queen/king at ranks 11-13) handled separately when
+ # a playing deck is seeded — Sprint A.2 covers tarot families only.
+ _COURT_NAME_BY_RANK = {11: "page", 12: "knight", 13: "queen", 14: "king"}
+
+ @property
+ def image_filename(self):
+ """v2-convention filename per [[reference-card-image-naming-convention]].
+ Always derives a path; the template decides whether to actually render
+ an
based on `deck_variant.has_card_images`."""
+ deck = self.deck_variant
+ if self.arcana == self.MAJOR:
+ return f"{deck.slug}-{deck.trump_category}-{self.number:02d}-{self.slug}.png"
+ # MINOR or MIDDLE: --[-].png
+ suit_slug = deck.suit_slug(self.suit)
+ rank = f"{self.number:02d}"
+ court = self._COURT_NAME_BY_RANK.get(self.number)
+ if court:
+ return f"{deck.slug}-{suit_slug}-{rank}-{court}.png"
+ return f"{deck.slug}-{suit_slug}-{rank}.png"
+
+ @property
+ def display_suit_name(self):
+ """Family-authentic capitalized suit label (e.g. 'Batons' for italian
+ BRANDS, 'Pentacles' for english CROWNS). Empty for major arcana."""
+ if not self.suit:
+ return ""
+ return self.deck_variant.suit_display(self.suit)
+
@property
def cautions_json(self):
import json
diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py
index 6145325..1d70f86 100644
--- a/src/apps/epic/tests/integrated/test_models.py
+++ b/src/apps/epic/tests/integrated/test_models.py
@@ -1149,3 +1149,152 @@ class MinchiateFiorentine1860SeedTest(TestCase):
TarotCard.objects.filter(deck_variant=self.deck, arcana="MIDDLE").count(),
0,
)
+
+
+class CardImageFilenameA2Test(TestCase):
+ """Sprint A.2 — `TarotCard.image_filename` + `display_suit_name` properties.
+
+ `image_filename` derives a v2-convention-compliant filename string per
+ [[reference-card-image-naming-convention]] using `deck_variant.family`
+ to translate canonical Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES)
+ into the family-authentic display slug (batons/coins/cups/swords for
+ italian; wands/pentacles/cups/swords for english; brands/crowns/grails/blades
+ for earthman). `display_suit_name` returns the capitalized version for
+ user-facing labels (tooltip, stat-block, card title).
+ """
+
+ def setUp(self):
+ self.minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
+ self.rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
+ self.earthman = DeckVariant.objects.get(slug="earthman")
+
+ # ── image_filename: Minchiate (italian family) trumps ───────────────────
+ def test_filename_il_matto(self):
+ card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto")
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
+ )
+
+ def test_filename_il_gobbo(self):
+ card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-gobbo")
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-trumps-11-il-gobbo.png",
+ )
+
+ def test_filename_le_trombe(self):
+ card = TarotCard.objects.get(deck_variant=self.minchiate, slug="le-trombe")
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-trumps-40-le-trombe.png",
+ )
+
+ def test_filename_l_acqua_w_apostrophe_restored(self):
+ card = TarotCard.objects.get(deck_variant=self.minchiate, slug="l-acqua")
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-trumps-21-l-acqua.png",
+ )
+
+ # ── image_filename: Minchiate (italian family) minors ───────────────────
+ def test_filename_ace_of_batons(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="BRANDS", number=1,
+ )
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-batons-01.png",
+ )
+
+ def test_filename_ten_of_coins(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="CROWNS", number=10,
+ )
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-coins-10.png",
+ )
+
+ def test_filename_page_of_cups_has_court_suffix(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="GRAILS", number=11,
+ )
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-cups-11-page.png",
+ )
+
+ def test_filename_king_of_swords_has_court_suffix(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="BLADES", number=14,
+ )
+ self.assertEqual(
+ card.image_filename,
+ "minchiate-fiorentine-1860-1890-swords-14-king.png",
+ )
+
+ # ── image_filename: RWS (english family) — derived path even when
+ # has_card_images=False (template decides whether to render the
) ──
+ def test_filename_rws_ace_of_cups_uses_english_suit_slug(self):
+ """Post-revocab, RWS Ace of Cups has suit=GRAILS but english-family
+ display slug 'cups'."""
+ card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-cups")
+ self.assertEqual(
+ card.image_filename,
+ "tarot-rider-waite-smith-cups-01.png",
+ )
+
+ def test_filename_rws_the_fool_uses_majors_category(self):
+ """English Tarot family uses 'majors' as the trump-category slug,
+ not 'trumps' (which is the Italian-Minchiate convention)."""
+ card = TarotCard.objects.get(deck_variant=self.rws, slug="the-fool")
+ self.assertEqual(
+ card.image_filename,
+ "tarot-rider-waite-smith-majors-00-the-fool.png",
+ )
+
+ def test_filename_rws_king_of_pentacles_uses_pentacles_slug(self):
+ """CROWNS canonical, english-family display 'pentacles'."""
+ card = TarotCard.objects.get(deck_variant=self.rws, slug="king-of-pentacles")
+ self.assertEqual(
+ card.image_filename,
+ "tarot-rider-waite-smith-pentacles-14-king.png",
+ )
+
+ # ── image_filename: Earthman (earthman family) ──────────────────────────
+ def test_filename_earthman_uses_earthman_suit_slug(self):
+ """Earthman family is identity-mapped: BRANDS→brands etc. Returns
+ a path even though Earthman.has_card_images=False — the template
+ decides whether to USE the path."""
+ card = TarotCard.objects.filter(
+ deck_variant=self.earthman, suit="BRANDS",
+ ).first()
+ self.assertTrue(card.image_filename.startswith("earthman-brands-"))
+
+ # ── display_suit_name ───────────────────────────────────────────────────
+ def test_display_suit_name_italian_brands_is_batons(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="BRANDS", number=1,
+ )
+ self.assertEqual(card.display_suit_name, "Batons")
+
+ def test_display_suit_name_italian_crowns_is_coins(self):
+ card = TarotCard.objects.get(
+ deck_variant=self.minchiate, suit="CROWNS", number=1,
+ )
+ self.assertEqual(card.display_suit_name, "Coins")
+
+ def test_display_suit_name_english_crowns_is_pentacles(self):
+ card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-pentacles")
+ self.assertEqual(card.display_suit_name, "Pentacles")
+
+ def test_display_suit_name_earthman_brands_is_brands(self):
+ card = TarotCard.objects.filter(
+ deck_variant=self.earthman, suit="BRANDS",
+ ).first()
+ self.assertEqual(card.display_suit_name, "Brands")
+
+ def test_display_suit_name_empty_for_major_arcana(self):
+ card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto")
+ self.assertEqual(card.display_suit_name, "")