diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 1259e5f..12fccde 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -647,20 +647,25 @@ def sig_deck_cards(room): def _sig_unique_cards_for_deck(deck_variant): """Return the 18 unique TarotCards forming one sig pile for the given deck variant. Shared between room sig-select (called via _sig_unique_cards - after room → deck_variant lookup) and the solo My Sig picker (called - via personal_sig_cards from User.equipped_deck).""" + after room → deck_variant lookup) and the solo My Sign picker (called + via personal_sig_cards from User.equipped_deck). + + "Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen, + 14=King) regardless of arcana classification: Earthman classifies its + courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine, + RWS) classify them as MINOR. Including both classifications gives every + deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at + numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just + because they don't use the MIDDLE classification. Cross-deck eligibility + is NOT segment-limited — all 4 suits' courts qualify per user spec + 2026-05-25. + """ if deck_variant is None: return [] - brands_crowns = list(TarotCard.objects.filter( + courts = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.BRANDS, TarotCard.CROWNS], - number__in=[11, 12, 13, 14], - )) - blades_grails = list(TarotCard.objects.filter( - deck_variant=deck_variant, - arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.BLADES, TarotCard.GRAILS], + arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR], + suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( @@ -668,7 +673,7 @@ def _sig_unique_cards_for_deck(deck_variant): arcana=TarotCard.MAJOR, number__in=[0, 1], )) - return brands_crowns + blades_grails + major + return courts + major def _sig_unique_cards(room): diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 26054b6..3eac2f6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -130,6 +130,51 @@ class GameboardViewTest(TestCase): empty_positions = {e.get("data-position") for e in empties} self.assertEqual(empty_positions, {"cover", "crown"}) + def test_my_sea_applet_renders_slots_even_when_user_significator_cleared(self): + """Regression (user bug 2026-05-25 PM): deleting User.significator + (via my-sign DEL) must NOT blank the My Sea applet on the gameboard + when the user has saved MySeaDraw slots. The MySeaDraw row carries + its own significator_id snapshot at first-draw-time so the saved + draw is durable even after the user clears their sig. Applet display + is now decoupled from `request.user.significator_id` — the sig-gate + Brief banner only fires for users with NO draws AND no sig.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + snapshot_sig = sig_pile[0] + card = TarotCard.objects.first() + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": card.id, + "reversed": False, "polarity": "gravity"}, + ], + significator_id=snapshot_sig.id, + ) + # User clears their sig AFTER the draw was saved (the bug repro). + self.user.significator = None + self.user.significator_reversed = False + self.user.save(update_fields=["significator", "significator_reversed"]) + self.assertIsNone(self.user.significator_id) + + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + # Slots render despite no current sig — the MySeaDraw row owns the + # display, not the user's live sig state. + filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled") + self.assertEqual( + len(filled), 1, + "Filled slot must persist even when User.significator_id is None", + ) + self.assertEqual(filled[0].get("data-card-id"), str(card.id)) + # And the "No draws yet" empty state must NOT render. + empties = parsed.cssselect("#id_applet_my_sea .my-sea-empty") + self.assertEqual( + len(empties), 0, + "Applet must not render 'No draws yet' when slots exist", + ) + def test_my_sea_applet_labels_match_locked_spread(self): """SAO label per spec: lay='Situation', cover='Action', crown='Outcome'. Empty slots still carry their label so the diff --git a/src/templates/apps/gameboard/_partials/_applet-my-sea.html b/src/templates/apps/gameboard/_partials/_applet-my-sea.html index bf5d4f3..e635894 100644 --- a/src/templates/apps/gameboard/_partials/_applet-my-sea.html +++ b/src/templates/apps/gameboard/_partials/_applet-my-sea.html @@ -9,14 +9,14 @@ {# Spread lock-in: the row is created at first card draw, so the moment #} {# 1+ cards exist all the spread's positions show in the applet. The #} {# scroll container handles overflow (mirrors the Palettes applet). #} - {% if not request.user.significator_id %} - {# Sprint 4b applet-gate — DRYly rendered as a project-wide Brief #} - {# banner (`note-banner` Gaussian-glass shell, portaled to the #} - {# page h2). Inline body falls through to the empty-state "No #} - {# draws yet" since no sig → no draws is the only possible state.#} - {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} -

No draws yet.

- {% elif my_sea_slots %} + {# Slot display is INDEPENDENT of `request.user.significator_id` — the #} + {# MySeaDraw row snapshots the sig at first-draw time (see #} + {# `gameboard.models.MySeaDraw` doc lines 130-132), so a subsequent #} + {# my-sign DEL doesn't invalidate the saved draw. The sig-gate Brief #} + {# banner only shows when the user has NO draws AND no sig — once draws #} + {# exist, they render regardless of current sig state (user spec #} + {# 2026-05-25 PM bug-report). #} + {% if my_sea_slots %}
{% for slot in my_sea_slots %} {% if slot.card %} @@ -72,6 +72,9 @@ {% endfor %}
{% else %} + {% if not request.user.significator_id %} + {% include "apps/gameboard/_partials/_my_sea_sign_gate_brief.html" %} + {% endif %}

No draws yet.

{% endif %}