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
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -148,6 +148,9 @@ def debit_token(user, slot, token):
|
|||||||
room.save()
|
room.save()
|
||||||
|
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
class TableSeat(models.Model):
|
class TableSeat(models.Model):
|
||||||
PC = "PC"
|
PC = "PC"
|
||||||
BC = "BC"
|
BC = "BC"
|
||||||
@@ -174,6 +177,10 @@ class TableSeat(models.Model):
|
|||||||
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
role_revealed = models.BooleanField(default=False)
|
role_revealed = models.BooleanField(default=False)
|
||||||
seat_position = models.IntegerField(null=True, blank=True)
|
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):
|
class DeckVariant(models.Model):
|
||||||
@@ -318,3 +325,52 @@ class TarotDeck(models.Model):
|
|||||||
"""Reset the deck so all variant cards are available again."""
|
"""Reset the deck so all variant cards are available again."""
|
||||||
self.drawn_card_ids = []
|
self.drawn_card_ids = []
|
||||||
self.save(update_fields=["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
|
||||||
|
|||||||
@@ -224,37 +224,16 @@ class RoomInviteTest(TestCase):
|
|||||||
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
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):
|
def _full_sig_room(name="Sig Room", role_order=None):
|
||||||
"""Return (room, gamers, earthman) with all 6 seats filled, roles assigned,
|
"""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:
|
if role_order is None:
|
||||||
role_order = SIG_SEAT_ORDER[:]
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
earthman = DeckVariant.objects.create(
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
slug="earthman", name="Earthman Deck", card_count=108, is_default=True
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
)
|
)
|
||||||
_make_sig_cards(earthman)
|
|
||||||
owner = User.objects.create(email="founder@sig.io")
|
owner = User.objects.create(email="founder@sig.io")
|
||||||
gamers = [owner]
|
gamers = [owner]
|
||||||
for i in range(2, 7):
|
for i in range(2, 7):
|
||||||
@@ -355,13 +334,12 @@ class SigCardFieldTest(TestCase):
|
|||||||
"""TableSeat.significator FK to TarotCard — default null, assignable."""
|
"""TableSeat.significator FK to TarotCard — default null, assignable."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
earthman = DeckVariant.objects.create(
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
slug="earthman", name="Earthman Deck", card_count=108, is_default=True
|
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,
|
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")
|
owner = User.objects.create(email="owner@test.io")
|
||||||
room = Room.objects.create(name="Field Test", owner=owner)
|
room = Room.objects.create(name="Field Test", owner=owner)
|
||||||
|
|||||||
@@ -775,35 +775,15 @@ class ReleaseSlotViewTest(TestCase):
|
|||||||
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
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):
|
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:
|
if role_order is None:
|
||||||
role_order = SIG_SEAT_ORDER[:]
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
earthman = DeckVariant.objects.create(
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
slug="earthman", name="Earthman Deck", card_count=108, is_default=True
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
)
|
)
|
||||||
_make_sig_cards(earthman)
|
|
||||||
founder = User.objects.create(email="founder@test.io")
|
founder = User.objects.create(email="founder@test.io")
|
||||||
gamers = [founder]
|
gamers = [founder]
|
||||||
for i in range(2, 7):
|
for i in range(2, 7):
|
||||||
@@ -896,10 +876,10 @@ class SelectSigCardViewTest(TestCase):
|
|||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_select_sig_card_not_in_deck_returns_400(self):
|
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(
|
other = TarotCard.objects.create(
|
||||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5,
|
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=[],
|
keywords_upright=[], keywords_reversed=[],
|
||||||
)
|
)
|
||||||
response = self._post(card_id=other.id)
|
response = self._post(card_id=other.id)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ urlpatterns = [
|
|||||||
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
||||||
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
||||||
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
||||||
|
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
||||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.drama.models import GameEvent, record
|
from apps.drama.models import GameEvent, record
|
||||||
from apps.epic.models import (
|
from apps.epic.models import (
|
||||||
GateSlot, Room, RoomInvite, TableSeat, TarotDeck,
|
GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck,
|
||||||
debit_token, select_token,
|
active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order,
|
||||||
)
|
)
|
||||||
from apps.lyric.models import Token
|
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):
|
def _expire_reserved_slots(room):
|
||||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||||
room.gate_slots.filter(
|
room.gate_slots.filter(
|
||||||
@@ -187,6 +194,8 @@ def _role_select_context(room, user):
|
|||||||
ctx["user_seat"] = user_seat
|
ctx["user_seat"] = user_seat
|
||||||
ctx["partner_seat"] = partner_seat
|
ctx["partner_seat"] = partner_seat
|
||||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
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
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -468,6 +477,32 @@ def gate_status(request, room_id):
|
|||||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
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
|
@login_required
|
||||||
def tarot_deck(request, room_id):
|
def tarot_deck(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
|
|||||||
@@ -22,16 +22,28 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% for slot in room.gate_slots.all %}
|
{% if room.table_status == "SIG_SELECT" and sig_seats %}
|
||||||
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}"
|
{% for seat in sig_seats %}
|
||||||
data-slot="{{ slot.slot_number }}">
|
<div class="table-seat" data-role="{{ seat.role }}" data-slot="{{ seat.slot_number }}">
|
||||||
<div class="seat-portrait">{{ slot.slot_number }}</div>
|
<div class="seat-portrait">{{ seat.slot_number }}</div>
|
||||||
<div class="seat-card-arc"></div>
|
<div class="seat-card-arc"></div>
|
||||||
<span class="seat-label">
|
<span class="seat-label">
|
||||||
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %}
|
{% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
{% for slot in room.gate_slots.all %}
|
||||||
|
<div class="table-seat{% if slot.slot_number == active_slot %} active{% endif %}"
|
||||||
|
data-slot="{{ slot.slot_number }}">
|
||||||
|
<div class="seat-portrait">{{ slot.slot_number }}</div>
|
||||||
|
<div class="seat-card-arc"></div>
|
||||||
|
<span class="seat-label">
|
||||||
|
{% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="id_inventory" class="room-inventory">
|
<div id="id_inventory" class="room-inventory">
|
||||||
<div id="id_inv_role_card">
|
<div id="id_inv_role_card">
|
||||||
@@ -60,6 +72,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if room.table_status == "SIG_SELECT" and sig_cards %}
|
||||||
|
<div id="id_sig_deck">
|
||||||
|
{% for card in sig_cards %}
|
||||||
|
<div class="sig-card" data-card-id="{{ card.id }}">{{ card.name }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
|
{% if not room.table_status and room.gate_status != "RENEWAL_DUE" %}
|
||||||
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user