From f5c2cf463627d0b4881064bbed6d9fc4153251b9 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 21 Mar 2026 14:33:06 -0400 Subject: [PATCH] in role-select.js, selectRole() runs in more precise ordering to ensure card hand for role selection passes to the next gamer after a selection is made; previous bug allowed multiple cards at a single gamer position, which prevented the card hand from making a circuit around the table before depletion; backend fixes including to apps.epic.views.select_role; +2 FTs & +1 IT asserts these features --- src/apps/epic/static/apps/epic/role-select.js | 4 +- src/apps/epic/tests/integrated/test_views.py | 18 ++++ src/apps/epic/views.py | 20 +++-- src/functional_tests/test_room_role_select.py | 88 +++++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index cf753d8..ef9e1c0 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -34,9 +34,11 @@ var RoleSelect = (function () { var invSlot = document.getElementById("id_inv_role_card"); if (invSlot) invSlot.appendChild(clean); - // Optimistically mark role as taken so re-opened fan filters it + // Immediately lock the stack — do not wait for WS turn_changed var stack = document.querySelector(".card-stack[data-starter-roles]"); if (stack) { + stack.dataset.state = "ineligible"; + stack.removeEventListener("click", openFan); var current = stack.dataset.starterRoles; stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; } diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index b476eca..e26cc96 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -628,6 +628,24 @@ class SelectRoleViewTest(TestCase): response, reverse("epic:gatekeeper", args=[self.room.id]) ) + def test_same_gamer_cannot_double_pick_sequentially(self): + """A second POST from the active gamer — after their role has been + saved — must redirect rather than assign a second role.""" + self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "PC"}, + ) + response = self.client.post( + reverse("epic:select_role", kwargs={"room_id": self.room.id}), + data={"role": "BC"}, + ) + self.assertRedirects( + response, reverse("epic:gatekeeper", args=[self.room.id]) + ) + self.assertEqual( + TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1 + ) + class RevealPhaseRenderingTest(TestCase): def setUp(self): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 47ece4a..5fed80f 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -3,6 +3,7 @@ from datetime import timedelta from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.contrib.auth.decorators import login_required +from django.db import transaction from django.http import HttpResponse from django.shortcuts import redirect, render from django.utils import timezone @@ -374,19 +375,20 @@ def select_role(request, room_id): room = Room.objects.get(id=room_id) if room.table_status != Room.ROLE_SELECT: return redirect("epic:gatekeeper", room_id=room_id) - active_seat = room.table_seats.filter( - role__isnull=True - ).order_by("slot_number").first() - if not active_seat or active_seat.gamer != request.user: - return redirect("epic:gatekeeper", room_id=room_id) role = request.POST.get("role") valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] if not role or role not in valid_roles: return redirect("epic:gatekeeper", room_id=room_id) - if room.table_seats.filter(role=role).exists(): - return HttpResponse(status=409) - active_seat.role = role - active_seat.save() + with transaction.atomic(): + active_seat = room.table_seats.select_for_update().filter( + role__isnull=True + ).order_by("slot_number").first() + if not active_seat or active_seat.gamer != request.user: + return redirect("epic:gatekeeper", room_id=room_id) + if room.table_seats.filter(role=role).exists(): + return HttpResponse(status=409) + active_seat.role = role + active_seat.save() record(room, GameEvent.ROLE_SELECTED, actor=request.user, role=role, slot_number=active_seat.slot_number, role_display=dict(TableSeat.ROLE_CHOICES).get(role, role)) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index be68b28..4715ea1 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -403,6 +403,94 @@ class RoleSelectTest(FunctionalTest): ) + # ------------------------------------------------------------------ # + # Test 4b — Stack locks out immediately after selection (no WS) # + # ------------------------------------------------------------------ # + + def test_card_stack_ineligible_immediately_after_selection(self): + """After clicking a role card the stack must flip to + data-state='ineligible' straight away — before any WS turn_changed + event could arrive. This test runs without a Channels server so + no WS event will fire; the fix must be entirely client-side.""" + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Lockout 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", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + ) + ).click() + self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) + self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() + + # No WS — only the JS fix can make this transition happen + self.wait_for( + lambda: self.assertEqual( + self.browser.find_element( + By.CSS_SELECTOR, ".card-stack" + ).get_attribute("data-state"), + "ineligible", + ) + ) + + def test_card_stack_cannot_be_reopened_after_selection(self): + """Clicking the card stack immediately after picking a role must + not open a second fan — the listener must have been removed.""" + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="No-reopen 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", + ]) + room.table_status = Room.ROLE_SELECT + room.save() + for slot in room.gate_slots.order_by("slot_number"): + TableSeat.objects.create( + room=room, gamer=slot.gamer, slot_number=slot.slot_number, + ) + room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" + + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + + # Open fan, pick a card + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" + ) + ).click() + self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) + self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() + + # Wait for fan to close (selectRole closes it synchronously) + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.ID, "id_role_select")), 0 + ) + ) + + # Attempt to reopen — must not work + self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click() + self.wait_for( + lambda: self.assertEqual( + len(self.browser.find_elements(By.ID, "id_role_select")), 0 + ) + ) + # ------------------------------------------------------------------ # # Test 7 — All roles revealed simultaneously after all gamers select # # ------------------------------------------------------------------ #