diff --git a/src/apps/epic/migrations/0011_nomad_schizo_icons.py b/src/apps/epic/migrations/0011_nomad_schizo_icons.py new file mode 100644 index 0000000..c5b2a99 --- /dev/null +++ b/src/apps/epic/migrations/0011_nomad_schizo_icons.py @@ -0,0 +1,43 @@ +"""Assign individual icons to The Nomad (0) and The Schizo (1). + +All other Major Arcana already have fa-hand-dots from migration 0010. +""" +from django.db import migrations + +ICONS = {0: 'fa-hat-cowboy', 1: 'fa-hat-wizard'} + + +def assign_icons(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + for number, icon in ICONS.items(): + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number + ).update(icon=icon) + + +def clear_icons(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number__in=list(ICONS.keys()) + ).update(icon="") + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0010_major_arcana_hand_dots_icon"), + ] + + operations = [ + migrations.RunPython(assign_icons, reverse_code=clear_icons), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 0a20f9a..c1711e8 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -285,7 +285,9 @@ class TarotCard(models.Model): if self.arcana == self.MAJOR: return self._to_roman(self.number) court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'} - return court.get(self.number, str(self.number)) + if self.number in court: + return court[self.number] + return 'A' if self.number == 1 else str(self.number) def emanation_for(self, polarity): """Return the upright title for a given polarity ('levity' or 'gravity'). diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 79a1230..7ad1f14 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1855,6 +1855,16 @@ class PickSeaRenderingTest(TestCase): self.assertIn("user_polarity", response.context) self.assertEqual(response.context["user_polarity"], "levity") # PC is levity + def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self): + """Characters created before the sig-sync fix have significator=None; fall back to seat.""" + Character.objects.create( + seat=self.pc_seat, + significator=None, + confirmed_at=timezone.now(), + ) + response = self.client.get(self.url) + self.assertEqual(response.context["my_tray_sig"], self.sig_card) + def test_my_tray_sig_comes_from_character_significator_when_confirmed(self): """When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat).""" char = Character.objects.create( diff --git a/src/apps/epic/tests/unit/test_models.py b/src/apps/epic/tests/unit/test_models.py new file mode 100644 index 0000000..cb1258c --- /dev/null +++ b/src/apps/epic/tests/unit/test_models.py @@ -0,0 +1,72 @@ +from django.test import SimpleTestCase + +from apps.epic.models import TarotCard + + +def _card(arcana, number, suit='', icon=''): + c = TarotCard() + c.arcana = arcana + c.number = number + c.suit = suit + c.icon = icon + return c + + +class TarotCardCornerRankTest(SimpleTestCase): + """TarotCard.corner_rank — alphanumeric display labels.""" + + def test_major_arcana_0_gives_0(self): + self.assertEqual(_card('MAJOR', 0).corner_rank, '0') + + def test_major_arcana_1_gives_roman_I(self): + self.assertEqual(_card('MAJOR', 1).corner_rank, 'I') + + def test_major_arcana_2_gives_roman_II(self): + self.assertEqual(_card('MAJOR', 2).corner_rank, 'II') + + def test_non_major_pip_1_gives_A(self): + """Ace — pip card number 1 should show 'A', not '1'.""" + self.assertEqual(_card('MIDDLE', 1, 'BRANDS').corner_rank, 'A') + + def test_non_major_pip_2_gives_2(self): + self.assertEqual(_card('MIDDLE', 2, 'BRANDS').corner_rank, '2') + + def test_non_major_pip_10_gives_10(self): + self.assertEqual(_card('MIDDLE', 10, 'CROWNS').corner_rank, '10') + + def test_court_maid_gives_M(self): + self.assertEqual(_card('MIDDLE', 11, 'GRAILS').corner_rank, 'M') + + def test_court_jack_gives_J(self): + self.assertEqual(_card('MIDDLE', 12, 'BLADES').corner_rank, 'J') + + def test_court_queen_gives_Q(self): + self.assertEqual(_card('MIDDLE', 13, 'BRANDS').corner_rank, 'Q') + + def test_court_king_gives_K(self): + self.assertEqual(_card('MIDDLE', 14, 'CROWNS').corner_rank, 'K') + + +class TarotCardSuitIconTest(SimpleTestCase): + """TarotCard.suit_icon — icon class resolution.""" + + def test_major_with_icon_returns_icon(self): + self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy').suit_icon, 'fa-hat-cowboy') + + def test_major_without_icon_returns_empty(self): + self.assertEqual(_card('MAJOR', 5).suit_icon, '') + + def test_brands_returns_wand_sparkles(self): + self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles') + + def test_grails_returns_trophy(self): + self.assertEqual(_card('MIDDLE', 11, 'GRAILS').suit_icon, 'fa-trophy') + + def test_blades_returns_gun(self): + self.assertEqual(_card('MIDDLE', 11, 'BLADES').suit_icon, 'fa-gun') + + def test_crowns_returns_crown(self): + self.assertEqual(_card('MIDDLE', 11, 'CROWNS').suit_icon, 'fa-crown') + + def test_icon_override_takes_priority_over_suit(self): + self.assertEqual(_card('MIDDLE', 11, 'CROWNS', icon='fa-star').suit_icon, 'fa-star') diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index a9c827e..2b7e8bf 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -371,7 +371,8 @@ def _role_select_context(room, user): ctx["sky_confirmed"] = sky_confirmed ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else '' if sky_confirmed: - ctx["my_tray_sig"] = confirmed_char.significator + # Fall back to seat.significator for Characters created before the sync was added + ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator return ctx diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index a339b64..26aedc7 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -940,11 +940,14 @@ $sea-card-h: 6.5rem; writing-mode: vertical-rl; transform: rotate(180deg); text-transform: uppercase; - font-size: 0.6rem; - letter-spacing: 0.15em; - opacity: 0.45; + // Fill the full card height ($sea-card-h: 6.5rem) with 5 letters + font-size: 1rem; + letter-spacing: 0.32em; + font-weight: 700; + opacity: 0.5; white-space: nowrap; flex-shrink: 0; + align-self: center; } .sea-deck-stack { @@ -966,6 +969,7 @@ $sea-card-h: 6.5rem; align-items: center; justify-content: center; transition: box-shadow 0.15s; + z-index: 1; // sits above the name label } .sea-stack-ok { @@ -976,11 +980,19 @@ $sea-card-h: 6.5rem; z-index: 5; } +.sea-deck-stack { gap: 0; } // remove gap so name slides under the face + .sea-stack-name { - font-size: 0.6rem; - letter-spacing: 0.06em; + font-size: 0.65rem; + letter-spacing: 0.08em; text-transform: uppercase; font-weight: 600; + opacity: 0.6; + // Pull top of label partially under the stack face + // margin-top: -0.1rem; + transform: scaleY(1.2); + transform-origin: top center; + z-index: 0; } .sea-deck-stack--gravity .sea-stack-name { color: rgba(var(--quaUser), 1); } .sea-deck-stack--levity .sea-stack-name { color: rgba(var(--terUser), 1); }