From b3bc422f46d0ef966ae1421541e6cc361746f8da Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 25 Mar 2026 01:50:06 -0400 Subject: [PATCH] new migrations in apps.epic for .models additions, incl. Significator select order (= Start Role seat order), which cards of whom go into which deck, which are brought into Sig select; new select-sig urlpattern in .views; room.html supports this stage of game now --- .../0017_tableseat_significator_fk.py | 19 +++++++ src/apps/epic/models.py | 56 +++++++++++++++++++ src/apps/epic/tests/integrated/test_models.py | 40 +++---------- src/apps/epic/tests/integrated/test_views.py | 34 +++-------- src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 39 ++++++++++++- src/templates/apps/gameboard/room.html | 40 +++++++++---- 7 files changed, 159 insertions(+), 70 deletions(-) create mode 100644 src/apps/epic/migrations/0017_tableseat_significator_fk.py diff --git a/src/apps/epic/migrations/0017_tableseat_significator_fk.py b/src/apps/epic/migrations/0017_tableseat_significator_fk.py new file mode 100644 index 0000000..56233ce --- /dev/null +++ b/src/apps/epic/migrations/0017_tableseat_significator_fk.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0 on 2026-03-25 05:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0016_reorder_earthman_popes'), + ] + + operations = [ + migrations.AddField( + model_name='tableseat', + name='significator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='significator_seats', to='epic.tarotcard'), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 0c97535..fb39d57 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -148,6 +148,9 @@ def debit_token(user, slot, token): room.save() +SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"] + + class TableSeat(models.Model): PC = "PC" BC = "BC" @@ -174,6 +177,10 @@ class TableSeat(models.Model): role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True) role_revealed = models.BooleanField(default=False) seat_position = models.IntegerField(null=True, blank=True) + significator = models.ForeignKey( + "TarotCard", null=True, blank=True, + on_delete=models.SET_NULL, related_name="significator_seats", + ) class DeckVariant(models.Model): @@ -318,3 +325,52 @@ class TarotDeck(models.Model): """Reset the deck so all variant cards are available again.""" self.drawn_card_ids = [] self.save(update_fields=["drawn_card_ids"]) + + +# ── Significator deck helpers ───────────────────────────────────────────────── + +def sig_deck_cards(room): + """Return 36 TarotCard objects forming the Significator deck (18 unique × 2). + + PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique + SC/AC pair → SWORDS + CUPS court cards (numbers 11–14): 8 unique + NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique + Total: 18 unique × 2 (levity + gravity piles) = 36 cards. + """ + deck_variant = room.owner.equipped_deck + if deck_variant is None: + return [] + wands_pentacles = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MINOR, + suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], + number__in=[11, 12, 13, 14], + )) + swords_cups = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MINOR, + suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + number__in=[11, 12, 13, 14], + )) + major = list(TarotCard.objects.filter( + deck_variant=deck_variant, + arcana=TarotCard.MAJOR, + number__in=[0, 1], + )) + unique_cards = wands_pentacles + swords_cups + major # 18 unique + return unique_cards + unique_cards # × 2 = 36 + + +def sig_seat_order(room): + """Return TableSeats in canonical PC→NC→EC→SC→AC→BC order.""" + _order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)} + seats = list(room.table_seats.all()) + return sorted(seats, key=lambda s: _order.get(s.role, 99)) + + +def active_sig_seat(room): + """Return the first seat without a significator in canonical order, or None.""" + for seat in sig_seat_order(room): + if seat.significator_id is None: + return seat + return None diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index ec67170..45cf059 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -224,37 +224,16 @@ class RoomInviteTest(TestCase): SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"] -def _make_sig_cards(deck): - """Create the 18 unique TarotCard types used in the Significator deck.""" - for suit in ["WANDS", "CUPS", "SWORDS", "PENTACLES"]: - for number, court in [(11, "Maid"), (12, "Jack"), (13, "Queen"), (14, "King")]: - TarotCard.objects.create( - deck_variant=deck, arcana="MINOR", suit=suit, number=number, - name=f"{court} of {suit.capitalize()}", - slug=f"{court.lower()}-of-{suit.lower()}-em", - keywords_upright=[], keywords_reversed=[], - ) - TarotCard.objects.create( - deck_variant=deck, arcana="MAJOR", number=0, - name="The Schiz", slug="the-schiz", - keywords_upright=[], keywords_reversed=[], - ) - TarotCard.objects.create( - deck_variant=deck, arcana="MAJOR", number=1, - name="Pope 1: Chancellor", slug="pope-1-chancellor", - keywords_upright=[], keywords_reversed=[], - ) - - def _full_sig_room(name="Sig Room", role_order=None): """Return (room, gamers, earthman) with all 6 seats filled, roles assigned, - table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman.""" + table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman. + Uses get_or_create for DeckVariant — migration data persists in TestCase.""" if role_order is None: role_order = SIG_SEAT_ORDER[:] - earthman = DeckVariant.objects.create( - slug="earthman", name="Earthman Deck", card_count=108, is_default=True + earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) - _make_sig_cards(earthman) owner = User.objects.create(email="founder@sig.io") gamers = [owner] for i in range(2, 7): @@ -355,13 +334,12 @@ class SigCardFieldTest(TestCase): """TableSeat.significator FK to TarotCard — default null, assignable.""" def setUp(self): - earthman = DeckVariant.objects.create( - slug="earthman", name="Earthman Deck", card_count=108, is_default=True + earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) - self.card = TarotCard.objects.create( + self.card = TarotCard.objects.get( deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11, - name="Maid of Wands", slug="maid-of-wands-em", - keywords_upright=[], keywords_reversed=[], ) owner = User.objects.create(email="owner@test.io") room = Room.objects.create(name="Field Test", owner=owner) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index de277e6..a1bf9c0 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -775,35 +775,15 @@ class ReleaseSlotViewTest(TestCase): SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"] -def _make_sig_cards(deck): - for suit in ["WANDS", "CUPS", "SWORDS", "PENTACLES"]: - for number, court in [(11, "Maid"), (12, "Jack"), (13, "Queen"), (14, "King")]: - TarotCard.objects.create( - deck_variant=deck, arcana="MINOR", suit=suit, number=number, - name=f"{court} of {suit.capitalize()}", - slug=f"{court.lower()}-of-{suit.lower()}-em", - keywords_upright=[], keywords_reversed=[], - ) - TarotCard.objects.create( - deck_variant=deck, arcana="MAJOR", number=0, - name="The Schiz", slug="the-schiz", - keywords_upright=[], keywords_reversed=[], - ) - TarotCard.objects.create( - deck_variant=deck, arcana="MAJOR", number=1, - name="Pope 1: Chancellor", slug="pope-1-chancellor", - keywords_upright=[], keywords_reversed=[], - ) - - def _full_sig_setUp(test_case, role_order=None): - """Populate test_case with a SIG_SELECT room; return (room, gamers, earthman, card_in_deck).""" + """Populate test_case with a SIG_SELECT room; return (room, gamers, earthman, card_in_deck). + Uses get_or_create for DeckVariant — migration data persists in TestCase.""" if role_order is None: role_order = SIG_SEAT_ORDER[:] - earthman = DeckVariant.objects.create( - slug="earthman", name="Earthman Deck", card_count=108, is_default=True + earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) - _make_sig_cards(earthman) founder = User.objects.create(email="founder@test.io") gamers = [founder] for i in range(2, 7): @@ -896,10 +876,10 @@ class SelectSigCardViewTest(TestCase): self.assertEqual(response.status_code, 403) def test_select_sig_card_not_in_deck_returns_400(self): - # Create a card that is not in the sig deck (e.g. a pip card) + # Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1) other = TarotCard.objects.create( deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5, - name="Five of Wands", slug="five-of-wands-em", + name="Five of Wands Test", slug="five-of-wands-test", keywords_upright=[], keywords_reversed=[], ) response = self._post(card_id=other.id) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index ea39cfe..a723ff3 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('room//gate/release_slot', views.release_slot, name='release_slot'), path('room//pick-roles', views.pick_roles, name='pick_roles'), path('room//select-role', views.select_role, name='select_role'), + path('room//select-sig', views.select_sig, name='select_sig'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index a8cd006..13bc51b 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -10,8 +10,8 @@ from django.utils import timezone from apps.drama.models import GameEvent, record from apps.epic.models import ( - GateSlot, Room, RoomInvite, TableSeat, TarotDeck, - debit_token, select_token, + GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck, + active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order, ) from apps.lyric.models import Token @@ -64,6 +64,13 @@ def _notify_role_select_start(room_id): ) +def _notify_sig_selected(room_id): + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'sig_selected'}, + ) + + def _expire_reserved_slots(room): cutoff = timezone.now() - RESERVE_TIMEOUT room.gate_slots.filter( @@ -187,6 +194,8 @@ def _role_select_context(room, user): ctx["user_seat"] = user_seat ctx["partner_seat"] = partner_seat ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number") + ctx["sig_cards"] = sig_deck_cards(room) + ctx["sig_seats"] = sig_seat_order(room) return ctx @@ -468,6 +477,32 @@ def gate_status(request, room_id): return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) +@login_required +def select_sig(request, room_id): + if request.method != "POST": + return redirect("epic:gatekeeper", room_id=room_id) + room = Room.objects.get(id=room_id) + if room.table_status != Room.SIG_SELECT: + return redirect("epic:gatekeeper", room_id=room_id) + active_seat = active_sig_seat(room) + if active_seat is None or active_seat.gamer != request.user: + return HttpResponse(status=403) + card_id = request.POST.get("card_id") + try: + card = TarotCard.objects.get(pk=card_id) + except TarotCard.DoesNotExist: + return HttpResponse(status=400) + sig_card_ids = {c.pk for c in sig_deck_cards(room)} + if card.pk not in sig_card_ids: + return HttpResponse(status=400) + if room.table_seats.filter(significator=card).exists(): + return HttpResponse(status=409) + active_seat.significator = card + active_seat.save() + _notify_sig_selected(room_id) + return HttpResponse(status=200) + + @login_required def tarot_deck(request, room_id): room = Room.objects.get(id=room_id) diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index bdc3078..be3c2bd 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -22,16 +22,28 @@ {% endif %} - {% for slot in room.gate_slots.all %} -
-
{{ slot.slot_number }}
-
- - {% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} - -
- {% endfor %} + {% if room.table_status == "SIG_SELECT" and sig_seats %} + {% for seat in sig_seats %} +
+
{{ seat.slot_number }}
+
+ + {% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %} + +
+ {% endfor %} + {% else %} + {% for slot in room.gate_slots.all %} +
+
{{ slot.slot_number }}
+
+ + {% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} + +
+ {% endfor %} + {% endif %}
@@ -60,6 +72,14 @@
+ {% if room.table_status == "SIG_SELECT" and sig_cards %} +
+ {% for card in sig_cards %} +
{{ card.name }}
+ {% endfor %} +
+ {% endif %} + {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %} {% endif %}