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
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Disco DeDisco
2026-03-21 14:33:06 -04:00
parent 91e0eaad8e
commit f5c2cf4636
4 changed files with 120 additions and 10 deletions

View File

@@ -34,9 +34,11 @@ var RoleSelect = (function () {
var invSlot = document.getElementById("id_inv_role_card"); var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.appendChild(clean); 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]"); var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack) { if (stack) {
stack.dataset.state = "ineligible";
stack.removeEventListener("click", openFan);
var current = stack.dataset.starterRoles; var current = stack.dataset.starterRoles;
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
} }

View File

@@ -628,6 +628,24 @@ class SelectRoleViewTest(TestCase):
response, reverse("epic:gatekeeper", args=[self.room.id]) 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): class RevealPhaseRenderingTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -3,6 +3,7 @@ from datetime import timedelta
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone from django.utils import timezone
@@ -374,19 +375,20 @@ def select_role(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
if room.table_status != Room.ROLE_SELECT: if room.table_status != Room.ROLE_SELECT:
return redirect("epic:gatekeeper", room_id=room_id) 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") role = request.POST.get("role")
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles: if not role or role not in valid_roles:
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
if room.table_seats.filter(role=role).exists(): with transaction.atomic():
return HttpResponse(status=409) active_seat = room.table_seats.select_for_update().filter(
active_seat.role = role role__isnull=True
active_seat.save() ).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, record(room, GameEvent.ROLE_SELECTED, actor=request.user,
role=role, slot_number=active_seat.slot_number, role=role, slot_number=active_seat.slot_number,
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role)) role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))

View File

@@ -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 # # Test 7 — All roles revealed simultaneously after all gamers select #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #