updated .fa-ban icon to update via js & ws; changed taken_roles (or its cognates) everywhere to starter_roles, as 'taken' will be used in respect to roles thru-out entire game, not just this seat-determining phase of Role Select; patched up chosen cards not disappearing upon previous gamer choice, & a try,except that catches attempts to select one anyway w. a 409 & optimistic card rollback; new IT confirms this 409
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Disco DeDisco
2026-03-18 23:14:53 -04:00
parent 4f076165ef
commit 8c2a5d24ec
5 changed files with 51 additions and 18 deletions

View File

@@ -34,11 +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);
// Update the stack's taken-roles so the next openFan() filters correctly // Optimistically mark role as taken so re-opened fan filters it
var stack = document.querySelector(".card-stack[data-taken-roles]"); var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack) { if (stack) {
var current = stack.dataset.takenRoles; var current = stack.dataset.starterRoles;
stack.dataset.takenRoles = current ? current + "," + roleCode : roleCode; stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
} }
var url = getSelectRoleUrl(); var url = getSelectRoleUrl();
@@ -50,20 +50,30 @@ var RoleSelect = (function () {
"X-CSRFToken": getCsrf(), "X-CSRFToken": getCsrf(),
}, },
body: "role=" + encodeURIComponent(roleCode), body: "role=" + encodeURIComponent(roleCode),
}).then(function (response) {
if (!response.ok) {
// Server rejected (role already taken) — undo optimistic update
if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean);
if (stack) {
stack.dataset.starterRoles = stack.dataset.starterRoles
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
}
openFan();
}
}); });
} }
function getTakenRoles() { function getStarterRoles() {
var stack = document.querySelector(".card-stack[data-taken-roles]"); var stack = document.querySelector(".card-stack[data-starter-roles]");
if (!stack) return []; if (!stack) return [];
var raw = stack.dataset.takenRoles; var raw = stack.dataset.starterRoles;
return raw ? raw.split(",").map(function (s) { return s.trim(); }) : []; return raw ? raw.split(",").map(function (s) { return s.trim(); }) : [];
} }
function openFan() { function openFan() {
if (document.querySelector(".role-select-backdrop")) return; if (document.querySelector(".role-select-backdrop")) return;
var taken = getTakenRoles(); var taken = getStarterRoles();
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; }); var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
var backdrop = document.createElement("div"); var backdrop = document.createElement("div");
@@ -124,18 +134,30 @@ var RoleSelect = (function () {
var invSlot = document.getElementById("id_inv_role_card"); var invSlot = document.getElementById("id_inv_role_card");
if (invSlot) invSlot.innerHTML = ""; if (invSlot) invSlot.innerHTML = "";
// Update card-stack eligibility
var stack = document.querySelector(".card-stack[data-user-slots]"); var stack = document.querySelector(".card-stack[data-user-slots]");
if (stack) { if (stack) {
// Sync starter-roles from server so the fan reflects actual DB state
if (event.detail.starter_roles) {
stack.dataset.starterRoles = event.detail.starter_roles.join(",");
}
// Update eligibility and ban icon together
var userSlots = stack.dataset.userSlots var userSlots = stack.dataset.userSlots
? stack.dataset.userSlots.split(",") : []; ? stack.dataset.userSlots.split(",") : [];
if (userSlots.indexOf(active) !== -1) { if (userSlots.indexOf(active) !== -1) {
stack.dataset.state = "eligible"; stack.dataset.state = "eligible";
var ban = stack.querySelector(".fa-ban");
if (ban) ban.remove();
stack.removeEventListener("click", openFan); stack.removeEventListener("click", openFan);
stack.addEventListener("click", openFan); stack.addEventListener("click", openFan);
} else { } else {
stack.dataset.state = "ineligible"; stack.dataset.state = "ineligible";
stack.removeEventListener("click", openFan); stack.removeEventListener("click", openFan);
if (!stack.querySelector(".fa-ban")) {
var icon = document.createElement("i");
icon.className = "fa-solid fa-ban";
stack.appendChild(icon);
}
} }
} }

View File

@@ -593,14 +593,20 @@ class SelectRoleViewTest(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url) self.assertIn("/accounts/login/", response.url)
def test_select_role_redirects_to_room(self): def test_select_role_returns_ok(self):
response = self.client.post( response = self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}), reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"}, data={"role": "PC"},
) )
self.assertRedirects( self.assertEqual(response.status_code, 200)
response, reverse("epic:gatekeeper", args=[self.room.id])
def test_select_role_returns_409_for_duplicate_role(self):
TableSeat.objects.filter(room=self.room, slot_number=2).update(role="BC")
response = self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "BC"},
) )
self.assertEqual(response.status_code, 409)
class RevealPhaseRenderingTest(TestCase): class RevealPhaseRenderingTest(TestCase):

View File

@@ -26,9 +26,13 @@ def _notify_turn_changed(room_id):
room_id=room_id, role__isnull=True room_id=room_id, role__isnull=True
).order_by("slot_number").first() ).order_by("slot_number").first()
active_slot = active_seat.slot_number if active_seat else None active_slot = active_seat.slot_number if active_seat else None
starter_roles = list(
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
.values_list("role", flat=True)
)
async_to_sync(get_channel_layer().group_send)( async_to_sync(get_channel_layer().group_send)(
f'room_{room_id}', f'room_{room_id}',
{'type': 'turn_changed', 'active_slot': active_slot}, {'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
) )
@@ -147,7 +151,7 @@ def _role_select_context(room, user):
card_stack_state = "eligible" card_stack_state = "eligible"
else: else:
card_stack_state = "ineligible" card_stack_state = "ineligible"
taken_roles = list( starter_roles = list(
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True) room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
) )
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])} _action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
@@ -161,7 +165,7 @@ def _role_select_context(room, user):
active_slot = active_seat.slot_number if active_seat else None active_slot = active_seat.slot_number if active_seat else None
ctx = { ctx = {
"card_stack_state": card_stack_state, "card_stack_state": card_stack_state,
"taken_roles": taken_roles, "starter_roles": starter_roles,
"assigned_seats": assigned_seats, "assigned_seats": assigned_seats,
"user_seat": user_seat, "user_seat": user_seat,
"user_slots": list( "user_slots": list(
@@ -371,7 +375,7 @@ def select_role(request, room_id):
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(): if room.table_seats.filter(role=role).exists():
return redirect("epic:gatekeeper", room_id=room_id) return HttpResponse(status=409)
active_seat.role = role active_seat.role = role
active_seat.save() active_seat.save()
if room.table_seats.filter(role__isnull=True).exists(): if room.table_seats.filter(role__isnull=True).exists():
@@ -380,6 +384,7 @@ def select_role(request, room_id):
room.table_status = Room.SIG_SELECT room.table_status = Room.SIG_SELECT
room.save() room.save()
_notify_roles_revealed(room_id) _notify_roles_revealed(room_id)
return HttpResponse(status=200)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -180,7 +180,7 @@ describe("RoleSelect", () => {
stack.className = "card-stack"; stack.className = "card-stack";
stack.dataset.state = "ineligible"; stack.dataset.state = "ineligible";
stack.dataset.userSlots = "1"; stack.dataset.userSlots = "1";
stack.dataset.takenRoles = ""; stack.dataset.starterRoles = "";
testDiv.appendChild(stack); testDiv.appendChild(stack);
}); });

View File

@@ -13,7 +13,7 @@
<div class="table-center"> <div class="table-center">
{% if room.table_status == "ROLE_SELECT" and card_stack_state %} {% if room.table_status == "ROLE_SELECT" and card_stack_state %}
<div class="card-stack" data-state="{{ card_stack_state }}" <div class="card-stack" data-state="{{ card_stack_state }}"
data-taken-roles="{{ taken_roles|join:',' }}" data-starter-roles="{{ starter_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}"> data-user-slots="{{ user_slots|join:',' }}">
{% if card_stack_state == "ineligible" %} {% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i> <i class="fa-solid fa-ban"></i>