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()
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 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 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):
@@ -214,3 +217,146 @@ class RoomInviteTest(TestCase):
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
).distinct()
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 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):
@@ -766,3 +768,172 @@ class ReleaseSlotViewTest(TestCase):
)
self.room.refresh_from_db()
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>/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-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/status', views.gate_status, name='gate_status'),
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.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)

View File

@@ -100,7 +100,10 @@ class GameKitTest(FunctionalTest):
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(
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
)

View File

@@ -682,3 +682,280 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
))
finally:
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,6 +22,17 @@
{% endif %}
</div>
</div>
{% if room.table_status == "SIG_SELECT" and sig_seats %}
{% for seat in sig_seats %}
<div class="table-seat" data-role="{{ seat.role }}" data-slot="{{ seat.slot_number }}">
<div class="seat-portrait">{{ seat.slot_number }}</div>
<div class="seat-card-arc"></div>
<span class="seat-label">
{% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %}
</span>
</div>
{% 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 }}">
@@ -32,6 +43,7 @@
</span>
</div>
{% endfor %}
{% endif %}
</div>
<div id="id_inventory" class="room-inventory">
<div id="id_inv_role_card">
@@ -60,6 +72,14 @@
</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" %}
{% include "apps/gameboard/_partials/_gatekeeper.html" %}
{% endif %}