Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
This commit is contained in:
@@ -1,17 +1,60 @@
|
||||
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.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
||||
from apps.lyric.models import Token
|
||||
|
||||
|
||||
RESERVE_TIMEOUT = timedelta(seconds=60)
|
||||
|
||||
|
||||
def _notify_gate_update(room_id):
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'gate_update'},
|
||||
)
|
||||
|
||||
|
||||
def _notify_turn_changed(room_id):
|
||||
active_seat = TableSeat.objects.filter(
|
||||
room_id=room_id, role__isnull=True
|
||||
).order_by("slot_number").first()
|
||||
active_slot = active_seat.slot_number if active_seat else None
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'turn_changed', 'active_slot': active_slot},
|
||||
)
|
||||
|
||||
|
||||
def _notify_roles_revealed(room_id):
|
||||
assignments = {
|
||||
str(seat.slot_number): seat.role
|
||||
for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number")
|
||||
}
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'roles_revealed', 'assignments': assignments},
|
||||
)
|
||||
|
||||
|
||||
def _notify_role_select_start(room_id):
|
||||
slot_order = list(
|
||||
GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED)
|
||||
.order_by("slot_number")
|
||||
.values_list("slot_number", flat=True)
|
||||
)
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'room_{room_id}',
|
||||
{'type': 'role_select_start', 'slot_order': slot_order},
|
||||
)
|
||||
|
||||
|
||||
def _expire_reserved_slots(room):
|
||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||
room.gate_slots.filter(
|
||||
@@ -79,6 +122,65 @@ def _gate_context(room, user):
|
||||
}
|
||||
|
||||
|
||||
def _role_select_context(room, user):
|
||||
user_seat = None
|
||||
active_seat = None
|
||||
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
|
||||
if unassigned.exists():
|
||||
# Normal path — TableSeats present
|
||||
active_seat = unassigned.first()
|
||||
user_seat = None
|
||||
if user.is_authenticated:
|
||||
user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first()
|
||||
if user_seat and user_seat.slot_number == active_seat.slot_number:
|
||||
card_stack_state = "eligible"
|
||||
else:
|
||||
card_stack_state = "ineligible"
|
||||
else:
|
||||
# Fallback — no TableSeats yet; use GateSlot drop order
|
||||
active_slot = room.gate_slots.filter(
|
||||
status=GateSlot.FILLED
|
||||
).order_by("slot_number").first()
|
||||
if active_slot is None:
|
||||
card_stack_state = None
|
||||
elif user.is_authenticated and active_slot.gamer == user:
|
||||
card_stack_state = "eligible"
|
||||
else:
|
||||
card_stack_state = "ineligible"
|
||||
taken_roles = list(
|
||||
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"])}
|
||||
assigned_seats = (
|
||||
sorted(
|
||||
room.table_seats.filter(gamer=user, role__isnull=False),
|
||||
key=lambda s: _action_order.get(s.role, 99),
|
||||
)
|
||||
if user.is_authenticated else []
|
||||
)
|
||||
active_slot = active_seat.slot_number if active_seat else None
|
||||
ctx = {
|
||||
"card_stack_state": card_stack_state,
|
||||
"taken_roles": taken_roles,
|
||||
"assigned_seats": assigned_seats,
|
||||
"user_seat": user_seat,
|
||||
"user_slots": list(
|
||||
room.table_seats.filter(gamer=user, role__isnull=True)
|
||||
.order_by("slot_number")
|
||||
.values_list("slot_number", flat=True)
|
||||
) if user.is_authenticated else [],
|
||||
"active_slot": active_slot,
|
||||
}
|
||||
if room.table_status == Room.SIG_SELECT:
|
||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
|
||||
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
|
||||
ctx["user_seat"] = user_seat
|
||||
ctx["partner_seat"] = partner_seat
|
||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
||||
return ctx
|
||||
|
||||
|
||||
@login_required
|
||||
def create_room(request):
|
||||
if request.method == "POST":
|
||||
@@ -91,7 +193,10 @@ def create_room(request):
|
||||
|
||||
def gatekeeper(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
ctx = _gate_context(room, request.user)
|
||||
if room.table_status:
|
||||
ctx = _role_select_context(room, request.user)
|
||||
else:
|
||||
ctx = _gate_context(room, request.user)
|
||||
ctx["room"] = room
|
||||
return render(request, "apps/gameboard/room.html", ctx)
|
||||
|
||||
@@ -113,6 +218,7 @@ def drop_token(request, room_id):
|
||||
token.current_room = room
|
||||
token.save()
|
||||
request.session["kit_token_id"] = str(token.id)
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
@@ -127,6 +233,7 @@ def drop_token(request, room_id):
|
||||
slot.reserved_at = timezone.now()
|
||||
slot.save()
|
||||
request.session["kit_token_id"] = str(token.id)
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@@ -150,6 +257,7 @@ def confirm_token(request, room_id):
|
||||
if int(slot_number) > carte.slots_claimed:
|
||||
carte.slots_claimed = int(slot_number)
|
||||
carte.save()
|
||||
_notify_gate_update(room_id)
|
||||
else:
|
||||
slot = room.gate_slots.filter(
|
||||
gamer=request.user, status=GateSlot.RESERVED
|
||||
@@ -163,6 +271,7 @@ def confirm_token(request, room_id):
|
||||
token = select_token(request.user)
|
||||
if token:
|
||||
debit_token(request.user, slot, token)
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@@ -185,6 +294,7 @@ def return_token(request, room_id):
|
||||
carte.slots_claimed = 0
|
||||
carte.save()
|
||||
request.session.pop("kit_token_id", None)
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
slot = room.gate_slots.filter(
|
||||
gamer=request.user,
|
||||
@@ -214,6 +324,7 @@ def return_token(request, room_id):
|
||||
slot.debited_token_type = None
|
||||
slot.debited_token_expires_at = None
|
||||
slot.save()
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@@ -240,6 +351,52 @@ def release_slot(request, room_id):
|
||||
if room.gate_status == Room.OPEN:
|
||||
room.gate_status = Room.GATHERING
|
||||
room.save()
|
||||
_notify_gate_update(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@login_required
|
||||
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)
|
||||
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 redirect("epic:gatekeeper", room_id=room_id)
|
||||
active_seat.role = role
|
||||
active_seat.save()
|
||||
if room.table_seats.filter(role__isnull=True).exists():
|
||||
_notify_turn_changed(room_id)
|
||||
else:
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
_notify_roles_revealed(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
@login_required
|
||||
def pick_roles(request, room_id):
|
||||
if request.method == "POST":
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.gate_status == Room.OPEN:
|
||||
room.table_status = Room.ROLE_SELECT
|
||||
room.save()
|
||||
for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
|
||||
TableSeat.objects.create(
|
||||
room=room,
|
||||
gamer=slot.gamer,
|
||||
slot_number=slot.slot_number,
|
||||
)
|
||||
_notify_role_select_start(room_id)
|
||||
return redirect("epic:gatekeeper", room_id=room_id)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user