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");
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;
}

View File

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

View File

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