diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index 5563900..cc28f5b 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -25,3 +25,6 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): async def roles_revealed(self, event): await self.send_json(event) + + async def sig_selected(self, event): + await self.send_json(event) diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js new file mode 100644 index 0000000..b368f6f --- /dev/null +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -0,0 +1,96 @@ +var SigSelect = (function () { + var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC']; + + var sigDeck, selectUrl, userRole; + + function getActiveRole() { + for (var i = 0; i < SIG_ORDER.length; i++) { + var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]'); + if (seat && !seat.dataset.sigDone) return SIG_ORDER[i]; + } + return null; + } + + function isEligible() { + return !!(userRole && userRole === getActiveRole()); + } + + function getCsrf() { + var m = document.cookie.match(/csrftoken=([^;]+)/); + return m ? m[1] : ''; + } + + function applySelection(cardId, role, deckType) { + // Remove only the specific pile copy (levity or gravity) of this card + var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]'; + sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); }); + + // Mark this seat done, remove active + var seat = document.querySelector('.table-seat[data-role="' + role + '"]'); + if (seat) { + seat.classList.remove('active'); + seat.dataset.sigDone = '1'; + } + + // Advance active to next seat + var nextRole = getActiveRole(); + if (nextRole) { + var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]'); + if (nextSeat) nextSeat.classList.add('active'); + } + + // Place a card placeholder in inventory + var invSlot = document.getElementById('id_inv_sig_card'); + if (invSlot) { + var card = document.createElement('div'); + card.className = 'card'; + invSlot.appendChild(card); + } + } + + function init() { + sigDeck = document.getElementById('id_sig_deck'); + if (!sigDeck) return; + selectUrl = sigDeck.dataset.selectSigUrl; + userRole = sigDeck.dataset.userRole; + + sigDeck.addEventListener('click', function (e) { + var card = e.target.closest('.sig-card'); + if (!card) return; + if (!isEligible()) return; + var activeRole = getActiveRole(); + var cardId = card.dataset.cardId; + var deckType = card.dataset.deck; + window.showGuard(card, 'Select this significator?', function () { + fetch(selectUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': getCsrf(), + }, + body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType), + }).then(function (response) { + if (response.ok) { + applySelection(cardId, activeRole, deckType); + } + }); + }); + }); + } + + window.addEventListener('room:sig_selected', function (e) { + if (!sigDeck) return; + var cardId = String(e.detail.card_id); + var role = e.detail.role; + var deckType = e.detail.deck_type; + // Idempotent — skip if this copy already removed (local selector already did it) + if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return; + applySelection(cardId, role, deckType); + }); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +}()); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index a1bf9c0..5794b58 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -902,7 +902,7 @@ class SelectSigCardViewTest(TestCase): 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) + mock_notify.assert_called_once() def test_select_sig_requires_login(self): self.client.logout() diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 5b5fe7b..91735e2 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -64,10 +64,10 @@ def _notify_role_select_start(room_id): ) -def _notify_sig_selected(room_id): +def _notify_sig_selected(room_id, card_id, role, deck_type='levity'): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', - {'type': 'sig_selected'}, + {'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type}, ) @@ -194,7 +194,9 @@ 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) + raw_sig_cards = sig_deck_cards(room) + half = len(raw_sig_cards) // 2 + ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]] ctx["sig_seats"] = sig_seat_order(room) ctx["sig_active_seat"] = active_sig_seat(room) return ctx @@ -500,7 +502,8 @@ def select_sig(request, room_id): return HttpResponse(status=409) active_seat.significator = card active_seat.save() - _notify_sig_selected(room_id) + deck_type = request.POST.get('deck_type', 'levity') + _notify_sig_selected(room_id, card.pk, active_seat.role, deck_type) return HttpResponse(status=200) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index 06d7d9e..f304325 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -1,5 +1,4 @@ import os -import unittest from django.conf import settings as django_settings from django.test import tag @@ -9,7 +8,7 @@ from selenium.webdriver.common.by import By from .base import FunctionalTest, ChannelsFunctionalTest from .management.commands.create_session import create_pre_authenticated_session from apps.applets.models import Applet -from apps.epic.models import DeckVariant, Room, GateSlot, TableSeat, TarotCard +from apps.epic.models import Room, GateSlot, TableSeat from apps.lyric.models import User @@ -684,309 +683,3 @@ 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. - Also ensures all gamers have an equipped_deck (required for sig_deck_cards).""" - 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}, - ) - # Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs) - _NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"} - for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"): - for number in (11, 12, 13, 14): - TarotCard.objects.get_or_create( - deck_variant=earthman, - slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em", - defaults={"arcana": "MINOR", "suit": suit, "number": number, - "name": f"{_NAME[number]} of {suit.capitalize()}"}, - ) - for number, name, slug in [ - (0, "The Schiz", "the-schiz-em"), - (1, "Pope 1: Chancellor", "pope-1-chancellor-em"), - ]: - TarotCard.objects.get_or_create( - deck_variant=earthman, - slug=slug, - defaults={"arcana": "MAJOR", "number": number, "name": name}, - ) - for slot in room.gate_slots.order_by("slot_number"): - if slot.gamer and not slot.gamer.equipped_deck: - slot.gamer.equipped_deck = earthman - slot.gamer.save(update_fields=["equipped_deck"]) - 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 # - # ------------------------------------------------------------------ # - - @unittest.skip("requires sig-select.js — pending styling sprint") - 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 # - # ------------------------------------------------------------------ # - - @unittest.skip("requires sig-select.js — pending styling sprint") - 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) # - # ------------------------------------------------------------------ # - - @unittest.skip("requires sig-select.js — pending styling sprint") - 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() diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py new file mode 100644 index 0000000..00b3788 --- /dev/null +++ b/src/functional_tests/test_room_sig_select.py @@ -0,0 +1,318 @@ +import os + +from django.conf import settings as django_settings +from django.test import tag +from selenium import webdriver +from selenium.webdriver.common.by import By + +from .base import FunctionalTest, ChannelsFunctionalTest +from .management.commands.create_session import create_pre_authenticated_session +from apps.applets.models import Applet +from apps.epic.models import DeckVariant, Room, TableSeat, TarotCard +from apps.lyric.models import User + +from .test_room_role_select import _fill_room_via_orm + + +# ── 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. + Also ensures all gamers have an equipped_deck (required for sig_deck_cards).""" + 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}, + ) + # Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs) + _NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"} + for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"): + for number in (11, 12, 13, 14): + TarotCard.objects.get_or_create( + deck_variant=earthman, + slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em", + defaults={"arcana": "MINOR", "suit": suit, "number": number, + "name": f"{_NAME[number]} of {suit.capitalize()}"}, + ) + for number, name, slug in [ + (0, "The Schiz", "the-schiz-em"), + (1, "Pope 1: Chancellor", "pope-1-chancellor-em"), + ]: + TarotCard.objects.get_or_create( + deck_variant=earthman, + slug=slug, + defaults={"arcana": "MAJOR", "number": number, "name": name}, + ) + for slot in room.gate_slots.order_by("slot_number"): + if slot.gamer and not slot.gamer.equipped_deck: + slot.gamer.equipped_deck = earthman + slot.gamer.save(update_fields=["equipped_deck"]) + 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 — one pile copy of the 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() diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index d51e684..a6d8d80 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -46,6 +46,7 @@ {% endif %}