hex position indicators: chair icons at hex edge midpoints replace gate-slot circles
- Split .gate-overlay into .gate-backdrop (z-100, blur) + .gate-overlay modal (z-120) so .table-position elements (z-110) render above backdrop but below modal - New _table_positions.html partial: 6 .table-position divs with .fa-chair, role label, and .fa-ban/.fa-circle-check status icons; included unconditionally in room.html - New epic:room view at /gameboard/room/<uuid>/; gatekeeper redirects there when table_status set; pick_roles redirects there - role-select.js: adds .active glow to position on selectRole(); swaps .fa-ban→.fa-circle-check in placeCard onComplete; handleTurnChanged clears stale .active from all positions - FTs: PositionIndicatorsTest (5 tests) + RoleSelectTest 8a/8b (glow + check state) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,10 @@ var RoleSelect = (function () {
|
||||
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||
}
|
||||
|
||||
// Mark position as actively being seated (glow state)
|
||||
var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
||||
if (activePos) activePos.classList.add('active');
|
||||
|
||||
var url = getSelectRoleUrl();
|
||||
if (!url) return;
|
||||
fetch(url, {
|
||||
@@ -72,6 +76,13 @@ var RoleSelect = (function () {
|
||||
_animationPending = true;
|
||||
Tray.placeCard(roleCode, function () {
|
||||
_animationPending = false;
|
||||
// Swap ban → check and clear glow on the seated position
|
||||
var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]');
|
||||
if (seatedPos) {
|
||||
seatedPos.classList.remove('active');
|
||||
var ban = seatedPos.querySelector('.fa-ban');
|
||||
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
||||
}
|
||||
if (_pendingTurnChange) {
|
||||
var ev = _pendingTurnChange;
|
||||
_pendingTurnChange = null;
|
||||
@@ -178,6 +189,11 @@ var RoleSelect = (function () {
|
||||
_turnChangedBeforeFetch = true;
|
||||
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||
|
||||
// Clear any stale .active glow from position indicators
|
||||
document.querySelectorAll('.table-position.active').forEach(function (p) {
|
||||
p.classList.remove('active');
|
||||
});
|
||||
|
||||
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||
if (stack) {
|
||||
// Sync starter-roles from server so the fan reflects actual DB state
|
||||
|
||||
@@ -367,67 +367,68 @@ class RoleSelectRenderingTest(TestCase):
|
||||
self.room.save()
|
||||
for i, gamer in enumerate(self.gamers, start=1):
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_room_view_includes_card_stack_when_role_select(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, "card-stack")
|
||||
|
||||
def test_card_stack_eligible_for_slot1_gamer(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'data-state="eligible"')
|
||||
|
||||
def test_card_stack_ineligible_for_slot2_gamer(self):
|
||||
self.client.force_login(self.gamers[1])
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'data-state="ineligible"')
|
||||
|
||||
def test_card_stack_ineligible_shows_fa_ban(self):
|
||||
self.client.force_login(self.gamers[1])
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, "fa-ban")
|
||||
|
||||
def test_card_stack_eligible_omits_fa_ban(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertNotContains(response, "fa-ban")
|
||||
|
||||
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertNotContains(response, "gate-overlay")
|
||||
|
||||
def test_six_table_seats_rendered(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, "table-seat", count=6)
|
||||
|
||||
def test_active_table_seat_has_active_class(self):
|
||||
self.client.force_login(self.founder) # slot 1 is active
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'class="table-seat active"')
|
||||
|
||||
def test_inactive_table_seat_lacks_active_class(self):
|
||||
self.client.force_login(self.founder)
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
# Slots 2–6 are not active, so at least one plain table-seat exists
|
||||
self.assertContains(response, 'class="table-seat"')
|
||||
@@ -435,14 +436,14 @@ class RoleSelectRenderingTest(TestCase):
|
||||
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
||||
self.client.force_login(self.founder) # founder is slot 1 only
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'data-user-slots="1"')
|
||||
|
||||
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
|
||||
self.client.force_login(self.gamers[1]) # slot 2 gamer
|
||||
response = self.client.get(
|
||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url
|
||||
)
|
||||
self.assertContains(response, 'data-user-slots="2"')
|
||||
|
||||
@@ -495,7 +496,7 @@ class PickRolesViewTest(TestCase):
|
||||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||
response, reverse("epic:room", args=[self.room.id])
|
||||
)
|
||||
|
||||
def test_pick_roles_notifies_channel_layer(self):
|
||||
@@ -633,7 +634,7 @@ class SelectRoleViewTest(TestCase):
|
||||
data={"role": "BOGUS"},
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||
response, reverse("epic:room", args=[self.room.id])
|
||||
)
|
||||
|
||||
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||||
@@ -648,7 +649,7 @@ class SelectRoleViewTest(TestCase):
|
||||
data={"role": "BC"},
|
||||
)
|
||||
self.assertRedirects(
|
||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||
response, reverse("epic:room", args=[self.room.id])
|
||||
)
|
||||
self.assertEqual(
|
||||
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||||
@@ -778,7 +779,7 @@ class SigSelectRenderingTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||
self.url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_sig_deck_element_present(self):
|
||||
response = self.client.get(self.url)
|
||||
@@ -878,7 +879,7 @@ class SelectSigCardViewTest(TestCase):
|
||||
self.room.save()
|
||||
response = self._post()
|
||||
self.assertRedirects(
|
||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||
response, reverse("epic:room", args=[self.room.id])
|
||||
)
|
||||
|
||||
def test_select_sig_last_choice_does_not_advance_to_none(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ app_name = 'epic'
|
||||
|
||||
urlpatterns = [
|
||||
path('rooms/create_room', views.create_room, name='create_room'),
|
||||
path('room/<uuid:room_id>/', views.room_view, name='room'),
|
||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
||||
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
||||
|
||||
@@ -71,6 +71,17 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||
)
|
||||
|
||||
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
|
||||
def _gate_positions(room):
|
||||
"""Return list of dicts [{slot, role_label}] for _table_positions.html."""
|
||||
return [
|
||||
{"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")}
|
||||
for slot in room.gate_slots.order_by("slot_number")
|
||||
]
|
||||
|
||||
|
||||
def _expire_reserved_slots(room):
|
||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||
room.gate_slots.filter(
|
||||
@@ -135,6 +146,7 @@ def _gate_context(room, user):
|
||||
"carte_slots_claimed": carte_slots_claimed,
|
||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||
"carte_next_slot_number": carte_next_slot_number,
|
||||
"gate_positions": _gate_positions(room),
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +198,7 @@ def _role_select_context(room, user):
|
||||
.values_list("slot_number", flat=True)
|
||||
) if user.is_authenticated else [],
|
||||
"active_slot": active_slot,
|
||||
"gate_positions": _gate_positions(room),
|
||||
}
|
||||
if room.table_status == Room.SIG_SELECT:
|
||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||
@@ -215,9 +228,16 @@ def create_room(request):
|
||||
def gatekeeper(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status:
|
||||
ctx = _role_select_context(room, request.user)
|
||||
else:
|
||||
ctx = _gate_context(room, request.user)
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
ctx = _gate_context(room, request.user)
|
||||
ctx["room"] = room
|
||||
ctx["page_class"] = "page-gameboard"
|
||||
return render(request, "apps/gameboard/room.html", ctx)
|
||||
|
||||
|
||||
def room_view(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
ctx = _role_select_context(room, request.user)
|
||||
ctx["room"] = room
|
||||
ctx["page_class"] = "page-gameboard"
|
||||
return render(request, "apps/gameboard/room.html", ctx)
|
||||
@@ -390,17 +410,20 @@ def select_role(request, room_id):
|
||||
if request.method == "POST":
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status != Room.ROLE_SELECT:
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
return redirect(
|
||||
"epic:room" if room.table_status else "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)
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
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)
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
if room.table_seats.filter(role=role).exists():
|
||||
return HttpResponse(status=409)
|
||||
active_seat.role = role
|
||||
@@ -416,7 +439,7 @@ def select_role(request, room_id):
|
||||
record(room, GameEvent.ROLES_REVEALED)
|
||||
_notify_roles_revealed(room_id)
|
||||
return HttpResponse(status=200)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -433,7 +456,7 @@ def pick_roles(request, room_id):
|
||||
slot_number=slot.slot_number,
|
||||
)
|
||||
_notify_role_select_start(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
return redirect("epic:room", room_id=room_id)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -487,7 +510,10 @@ def select_sig(request, room_id):
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
return redirect(
|
||||
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||
room_id=room_id,
|
||||
)
|
||||
active_seat = active_sig_seat(room)
|
||||
if active_seat is None or active_seat.gamer != request.user:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
Reference in New Issue
Block a user