Compare commits

..

2 Commits

9 changed files with 743 additions and 15 deletions

View 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'),
),
]

View File

@@ -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 1114): 8 unique
SC/AC pair → SWORDS + CUPS court cards (numbers 1114): 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

View File

@@ -5,7 +5,10 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token from apps.epic.models import (
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
)
class RoomCreationTest(TestCase): class RoomCreationTest(TestCase):
@@ -214,3 +217,146 @@ class RoomInviteTest(TestCase):
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING) Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
).distinct() ).distinct()
self.assertIn(self.room, rooms) self.assertIn(self.room, rooms)
# ── Significator deck helpers ─────────────────────────────────────────────────
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
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.
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
if role_order is None:
role_order = SIG_SEAT_ORDER[:]
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
owner = User.objects.create(email="founder@sig.io")
gamers = [owner]
for i in range(2, 7):
gamers.append(User.objects.create(email=f"g{i}@sig.io"))
for gamer in gamers:
gamer.equipped_deck = earthman
gamer.save(update_fields=["equipped_deck"])
room = Room.objects.create(name=name, owner=owner)
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
slot = room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=room, gamer=gamer, slot_number=i,
role=role, role_revealed=True,
)
room.table_status = Room.SIG_SELECT
room.save()
return room, gamers, earthman
class SigDeckCompositionTest(TestCase):
"""sig_deck_cards(room) returns exactly 36 cards with correct suit/arcana split."""
def setUp(self):
self.room, self.gamers, self.earthman = _full_sig_room()
def test_sig_deck_returns_36_cards(self):
cards = sig_deck_cards(self.room)
self.assertEqual(len(cards), 36)
def test_sc_ac_contribute_court_cards_of_swords_and_cups(self):
cards = sig_deck_cards(self.room)
sc_ac = [c for c in cards if c.suit in ("SWORDS", "CUPS")]
# M/J/Q/K × 2 suits × 2 roles = 16
self.assertEqual(len(sc_ac), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self):
cards = sig_deck_cards(self.room)
pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")]
self.assertEqual(len(pc_bc), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
def test_nc_ec_contribute_schiz_and_chancellor(self):
cards = sig_deck_cards(self.room)
major = [c for c in cards if c.arcana == "MAJOR"]
self.assertEqual(len(major), 4)
self.assertEqual(sorted(c.number for c in major), [0, 0, 1, 1])
def test_each_card_appears_twice_once_per_pile(self):
"""18 unique card specs × 2 (levity + gravity) = 36 total."""
cards = sig_deck_cards(self.room)
slugs = [c.slug for c in cards]
unique_slugs = set(slugs)
self.assertEqual(len(unique_slugs), 18)
self.assertTrue(all(slugs.count(s) == 2 for s in unique_slugs))
class SigSeatOrderTest(TestCase):
"""sig_seat_order() and active_sig_seat() return seats in PC→NC→EC→SC→AC→BC order."""
def setUp(self):
# Assign roles in reverse of canonical order to prove reordering works
self.room, self.gamers, _ = _full_sig_room(
name="Order Room",
role_order=["BC", "AC", "SC", "EC", "NC", "PC"],
)
def test_sig_seat_order_returns_canonical_role_sequence(self):
seats = sig_seat_order(self.room)
self.assertEqual([s.role for s in seats], SIG_SEAT_ORDER)
def test_active_sig_seat_is_first_seat_without_significator(self):
seat = active_sig_seat(self.room)
self.assertEqual(seat.role, "PC")
def test_active_sig_seat_advances_after_significator_set(self):
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
earthman = DeckVariant.objects.get(slug="earthman")
card = TarotCard.objects.filter(deck_variant=earthman, arcana="MINOR").first()
pc_seat.significator = card
pc_seat.save()
seat = active_sig_seat(self.room)
self.assertEqual(seat.role, "NC")
def test_active_sig_seat_is_none_when_all_chosen(self):
earthman = DeckVariant.objects.get(slug="earthman")
cards = list(TarotCard.objects.filter(deck_variant=earthman))
for i, seat in enumerate(TableSeat.objects.filter(room=self.room)):
seat.significator = cards[i]
seat.save()
self.assertIsNone(active_sig_seat(self.room))
class SigCardFieldTest(TestCase):
"""TableSeat.significator FK to TarotCard — default null, assignable."""
def setUp(self):
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.card = TarotCard.objects.get(
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11,
)
owner = User.objects.create(email="owner@test.io")
room = Room.objects.create(name="Field Test", owner=owner)
self.seat = TableSeat.objects.create(room=room, gamer=owner, slot_number=1, role="PC")
def test_significator_defaults_to_none(self):
self.assertIsNone(self.seat.significator)
def test_significator_can_be_assigned(self):
self.seat.significator = self.card
self.seat.save()
self.seat.refresh_from_db()
self.assertEqual(self.seat.significator, self.card)
def test_significator_nullable_on_delete(self):
self.seat.significator = self.card
self.seat.save()
self.card.delete()
self.seat.refresh_from_db()
self.assertIsNone(self.seat.significator)

View File

@@ -6,7 +6,9 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat from apps.epic.models import (
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
)
class RoomCreationViewTest(TestCase): class RoomCreationViewTest(TestCase):
@@ -766,3 +768,172 @@ class ReleaseSlotViewTest(TestCase):
) )
self.room.refresh_from_db() self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.GATHERING) self.assertEqual(self.room.gate_status, Room.GATHERING)
# ── Significator Selection ────────────────────────────────────────────────────
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
def _full_sig_setUp(test_case, role_order=None):
"""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.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
founder = User.objects.create(email="founder@test.io")
gamers = [founder]
for i in range(2, 7):
gamers.append(User.objects.create(email=f"g{i}@test.io"))
for gamer in gamers:
gamer.equipped_deck = earthman
gamer.save(update_fields=["equipped_deck"])
room = Room.objects.create(name="Sig Room", owner=founder)
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
slot = room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=room, gamer=gamer, slot_number=i, role=role, role_revealed=True,
)
room.gate_status = Room.OPEN
room.table_status = Room.SIG_SELECT
room.save()
card_in_deck = TarotCard.objects.get(
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11
)
test_case.client.force_login(founder)
return room, gamers, earthman, card_in_deck
class SigSelectRenderingTest(TestCase):
"""Gate view at SIG_SELECT renders the Significator deck."""
def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
self.url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
def test_sig_deck_element_present(self):
response = self.client.get(self.url)
self.assertContains(response, "id_sig_deck")
def test_sig_deck_contains_36_sig_cards(self):
response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('class="sig-card"'), 36)
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
response = self.client.get(self.url)
content = response.content.decode()
positions = {role: content.find(f'data-role="{role}"') for role in SIG_SEAT_ORDER}
# Every role must appear
self.assertTrue(all(pos != -1 for pos in positions.values()))
# Rendered in canonical sequence
ordered = sorted(SIG_SEAT_ORDER, key=lambda r: positions[r])
self.assertEqual(ordered, SIG_SEAT_ORDER)
def test_sig_deck_not_present_during_role_select(self):
self.room.table_status = Room.ROLE_SELECT
self.room.save()
response = self.client.get(self.url)
self.assertNotContains(response, "id_sig_deck")
class SelectSigCardViewTest(TestCase):
"""select_sig view — records choice, enforces turn order, rejects bad input."""
def setUp(self):
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
# Founder is slot 1, role=PC — active first in canonical order
self.url = reverse("epic:select_sig", kwargs={"room_id": self.room.id})
def _post(self, card_id=None, client=None):
c = client or self.client
return c.post(self.url, data={"card_id": card_id or self.card.id})
def test_select_sig_records_choice_on_active_seat(self):
self._post()
seat = TableSeat.objects.get(room=self.room, role="PC")
self.assertEqual(seat.significator, self.card)
def test_select_sig_returns_200(self):
response = self._post()
self.assertEqual(response.status_code, 200)
def test_select_sig_wrong_turn_makes_no_change(self):
# Gamer 2 is NC — not their turn yet
self.client.force_login(self.gamers[1])
self._post()
seat = TableSeat.objects.get(room=self.room, role="NC")
self.assertIsNone(seat.significator)
def test_select_sig_wrong_turn_returns_403(self):
self.client.force_login(self.gamers[1])
response = self._post()
self.assertEqual(response.status_code, 403)
def test_select_sig_card_not_in_deck_returns_400(self):
# Create a pip card (number=5) — not in the sig deck (only court 1114 + major 01)
other = TarotCard.objects.create(
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5,
name="Five of Wands Test", slug="five-of-wands-test",
keywords_upright=[], keywords_reversed=[],
)
response = self._post(card_id=other.id)
self.assertEqual(response.status_code, 400)
def test_select_sig_card_already_taken_returns_409(self):
# Another seat already holds this card as their significator
nc_seat = TableSeat.objects.get(room=self.room, role="NC")
nc_seat.significator = self.card
nc_seat.save()
response = self._post()
self.assertEqual(response.status_code, 409)
def test_select_sig_advances_active_seat_to_nc(self):
self._post()
from apps.epic.models import active_sig_seat
seat = active_sig_seat(self.room)
self.assertEqual(seat.role, "NC")
def test_select_sig_notifies_ws(self):
with patch("apps.epic.views._notify_sig_selected") as mock_notify:
self._post()
mock_notify.assert_called_once_with(self.room.id)
def test_select_sig_requires_login(self):
self.client.logout()
response = self._post()
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_select_sig_wrong_phase_redirects(self):
self.room.table_status = Room.ROLE_SELECT
self.room.save()
response = self._post()
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_select_sig_last_choice_does_not_advance_to_none(self):
"""After all 6 significators chosen, active_sig_seat() is None —
no unhandled AttributeError in the view."""
cards = list(TarotCard.objects.filter(deck_variant=self.earthman, arcana="MINOR"))
seats_in_order = list(
TableSeat.objects.filter(room=self.room).order_by("slot_number")
)
# Assign all but the last (BC) manually
for seat, card in zip(seats_in_order[:-1], cards):
seat.significator = card
seat.save()
# BC gamer POSTs the final choice
bc_seat = TableSeat.objects.get(room=self.room, role="BC")
self.client.force_login(bc_seat.gamer)
last_card = TarotCard.objects.filter(
deck_variant=self.earthman, arcana="MAJOR", number=0
).first()
response = self.client.post(self.url, data={"card_id": last_card.id})
self.assertIn(response.status_code, (200, 302))

View File

@@ -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'),

View File

@@ -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)

View File

@@ -100,7 +100,10 @@ class GameKitTest(FunctionalTest):
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck" By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
) )
) )
ActionChains(self.browser).move_to_element(deck_el).perform() # Dispatch mouseenter via JS — more reliable than ActionChains in headless CI
self.browser.execute_script(
"arguments[0].dispatchEvent(new Event('mouseenter'))", deck_el
)
tooltip = self.browser.find_element( tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip" By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
) )

View File

@@ -682,3 +682,280 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
)) ))
finally: finally:
self.browser2.quit() self.browser2.quit()
# ── Significator Selection ────────────────────────────────────────────────────
#
# After all 6 roles are revealed the room enters SIG_SELECT. A 36-card
# Significator deck appears at the table centre; gamers pick in seat order
# (PC → NC → EC → SC → AC → BC). Selected cards are removed from the shared
# pile in real time via WebSocket, exactly as role selection works.
#
# Deck composition (18 unique cards × 2 — one from levity, one from gravity):
# SC / AC (Shepherd / Alchemist) → M/J/Q/K of Swords & Cups (16 cards)
# PC / BC (Player / Builder) → M/J/Q/K of Wands & Pentacles (16 cards)
# NC / EC (Narrator / Economist) → The Schiz (0) + Chancellor (1) ( 4 cards)
#
# Levity pile: SC, PC, NC contributions. Gravity pile: AC, BC, EC contributions.
# Cards retain the contributor's deck card-back — up to 6 distinct backs active.
#
# ─────────────────────────────────────────────────────────────────────────────
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
def _assign_all_roles(room, role_order=None):
"""Assign roles to all slots, reveal them, and advance to SIG_SELECT."""
if role_order is None:
role_order = SIG_SEAT_ORDER[:]
for slot in room.gate_slots.order_by("slot_number"):
TableSeat.objects.update_or_create(
room=room,
slot_number=slot.slot_number,
defaults={
"gamer": slot.gamer,
"role": role_order[slot.slot_number - 1],
"role_revealed": True,
},
)
room.table_status = Room.SIG_SELECT
room.save()
class SigSelectTest(FunctionalTest):
"""Significator Selection — non-WebSocket tests."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
# ------------------------------------------------------------------ #
# Test S1 — Significator deck of 36 cards appears at table centre #
# ------------------------------------------------------------------ #
def test_sig_deck_appears_with_36_cards_after_all_roles_revealed(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Sig Deck Test", owner=founder)
_fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room)
self.create_pre_authenticated_session("founder@test.io")
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
self.browser.get(room_url)
# Significator deck is visible at the table centre
sig_deck = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_sig_deck")
)
self.assertTrue(sig_deck.is_displayed())
# It contains exactly 36 cards
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
self.assertEqual(len(cards), 36)
# ------------------------------------------------------------------ #
# Test S2 — Seats reorder to canonical role sequence at SIG_SELECT #
# ------------------------------------------------------------------ #
def test_seats_display_in_pc_nc_ec_sc_ac_bc_order_after_reveal(self):
"""Slots were filled in arbitrary token-drop order; after roles are
revealed the seat portraits must appear in PC→NC→EC→SC→AC→BC order."""
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Seat Order Test", owner=founder)
# Assign roles in reverse of canonical order so the reordering is visible
_fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room, role_order=["BC", "AC", "SC", "EC", "NC", "PC"])
self.create_pre_authenticated_session("founder@test.io")
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sig_deck"))
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat[data-role]")
self.assertEqual(len(seats), 6)
roles_in_order = [s.get_attribute("data-role") for s in seats]
self.assertEqual(roles_in_order, SIG_SEAT_ORDER)
# ------------------------------------------------------------------ #
# Test S3 — First seat (PC) can select a significator; deck shrinks #
# ------------------------------------------------------------------ #
def test_first_seat_pc_can_select_significator_and_deck_shrinks(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="PC Select Test", owner=founder)
# Founder is assigned PC (slot 1 → first in canonical order → active)
_fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
self.create_pre_authenticated_session("founder@test.io")
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
self.browser.get(room_url)
# 36-card sig deck is present and the founder's seat is active
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
)
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
)
)
# Click the first card in the significator deck to select it
first_card = self.browser.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)
first_card.click()
self.confirm_guard()
# Deck now has 35 cards — selected card removed
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
35,
)
)
# Founder's significator appears in their inventory
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_inv_sig_card .card"
)
)
# Active seat advances to NC
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
)
)
# ------------------------------------------------------------------ #
# Test S4 — Ineligible seat cannot interact with sig deck #
# ------------------------------------------------------------------ #
def test_non_active_seat_cannot_select_significator(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
room = Room.objects.create(name="Ineligible Sig Test", owner=founder)
# Founder is NC (second in canonical order) — not first
_fill_room_via_orm(room, [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
_assign_all_roles(room, role_order=["NC", "PC", "EC", "SC", "AC", "BC"])
self.create_pre_authenticated_session("founder@test.io")
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
self.browser.get(room_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sig_deck"))
# Click a sig card — it must not trigger a selection (deck stays at 36)
self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card").click()
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
@tag("channels")
class SigSelectChannelsTest(ChannelsFunctionalTest):
"""Significator Selection — WebSocket tests."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
def _make_browser2(self, email):
session_key = create_pre_authenticated_session(email)
options = webdriver.FirefoxOptions()
if os.environ.get("HEADLESS"):
options.add_argument("--headless")
b = webdriver.Firefox(options=options)
b.get(self.live_server_url + "/404_no_such_url/")
b.add_cookie(dict(
name=django_settings.SESSION_COOKIE_NAME,
value=session_key,
path="/",
))
return b
# ------------------------------------------------------------------ #
# Test S5 — Selected sig card disappears for watching gamer (WS) #
# ------------------------------------------------------------------ #
def test_selected_sig_card_removed_from_deck_for_other_gamers(self):
founder, _ = User.objects.get_or_create(email="founder@test.io")
User.objects.get_or_create(email="watcher@test.io")
room = Room.objects.create(name="Sig WS Test", owner=founder)
_fill_room_via_orm(room, [
"founder@test.io", "watcher@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
])
# Founder is PC (active first); watcher is NC (second)
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
# Watcher loads room, sees 36 cards
self.create_pre_authenticated_session("watcher@test.io")
self.browser.get(room_url)
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
36,
)
)
# Founder picks a significator in second browser
self.browser2 = self._make_browser2("founder@test.io")
try:
self.browser2.get(room_url)
self.wait_for(lambda: self.browser2.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
))
self.browser2.find_element(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
).click()
self.confirm_guard(browser=self.browser2)
# Watcher's deck shrinks to 35 without a page reload
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
)),
35,
)
)
# Active seat advances to NC in both browsers
self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
self.wait_for(lambda: self.browser2.find_element(
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
))
finally:
self.browser2.quit()

View File

@@ -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 %}