2026-03-14 02:03:44 -04:00
|
|
|
from datetime import timedelta
|
|
|
|
|
|
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
2026-03-17 00:24:23 -04:00
|
|
|
from asgiref.sync import async_to_sync
|
|
|
|
|
from channels.layers import get_channel_layer
|
2026-03-13 00:31:17 -04:00
|
|
|
from django.contrib.auth.decorators import login_required
|
2026-03-21 14:33:06 -04:00
|
|
|
from django.db import transaction
|
2026-03-13 22:51:42 -04:00
|
|
|
from django.http import HttpResponse
|
2026-03-13 00:31:17 -04:00
|
|
|
from django.shortcuts import redirect, render
|
2026-03-14 02:03:44 -04:00
|
|
|
from django.utils import timezone
|
2026-03-12 15:05:02 -04:00
|
|
|
|
2026-03-19 15:48:59 -04:00
|
|
|
from apps.drama.models import GameEvent, record
|
2026-03-24 21:07:01 -04:00
|
|
|
from apps.epic.models import (
|
|
|
|
|
GateSlot, Room, RoomInvite, TableSeat, TarotDeck,
|
|
|
|
|
debit_token, select_token,
|
|
|
|
|
)
|
2026-03-15 16:08:34 -04:00
|
|
|
from apps.lyric.models import Token
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
RESERVE_TIMEOUT = timedelta(seconds=60)
|
|
|
|
|
|
|
|
|
|
|
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
2026-03-17 00:24:23 -04:00
|
|
|
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
|
2026-03-18 23:14:53 -04:00
|
|
|
starter_roles = list(
|
|
|
|
|
TableSeat.objects.filter(room_id=room_id, role__isnull=False)
|
|
|
|
|
.values_list("role", flat=True)
|
|
|
|
|
)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
async_to_sync(get_channel_layer().group_send)(
|
|
|
|
|
f'room_{room_id}',
|
2026-03-18 23:14:53 -04:00
|
|
|
{'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
|
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
2026-03-17 00:24:23 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
def _expire_reserved_slots(room):
|
|
|
|
|
cutoff = timezone.now() - RESERVE_TIMEOUT
|
|
|
|
|
room.gate_slots.filter(
|
|
|
|
|
status=GateSlot.RESERVED,
|
|
|
|
|
reserved_at__lt=cutoff,
|
|
|
|
|
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _gate_context(room, user):
|
|
|
|
|
_expire_reserved_slots(room)
|
|
|
|
|
slots = room.gate_slots.order_by("slot_number")
|
|
|
|
|
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
|
|
|
|
|
user_reserved_slot = None
|
|
|
|
|
user_filled_slot = None
|
2026-03-16 00:07:52 -04:00
|
|
|
carte_token = None
|
|
|
|
|
carte_slots_claimed = 0
|
|
|
|
|
carte_nvm_slot_number = None
|
2026-03-16 01:04:52 -04:00
|
|
|
carte_next_slot_number = None
|
2026-03-14 02:03:44 -04:00
|
|
|
if user.is_authenticated:
|
|
|
|
|
user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
|
|
|
|
|
user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
|
2026-03-16 00:07:52 -04:00
|
|
|
carte_token = user.tokens.filter(
|
|
|
|
|
token_type=Token.CARTE, current_room=room
|
|
|
|
|
).first()
|
|
|
|
|
if carte_token:
|
|
|
|
|
carte_slots_claimed = carte_token.slots_claimed
|
|
|
|
|
# NVM shown on the highest-numbered slot this user filled via CARTE
|
|
|
|
|
nvm_slot = slots.filter(
|
|
|
|
|
debited_token_type=Token.CARTE, gamer=user, status=GateSlot.FILLED
|
|
|
|
|
).order_by("-slot_number").first()
|
|
|
|
|
if nvm_slot:
|
|
|
|
|
carte_nvm_slot_number = nvm_slot.slot_number
|
2026-03-16 01:04:52 -04:00
|
|
|
# Only the very next empty slot gets an OK button
|
|
|
|
|
next_slot = slots.filter(status=GateSlot.EMPTY).order_by("slot_number").first()
|
|
|
|
|
if next_slot:
|
|
|
|
|
carte_next_slot_number = next_slot.slot_number
|
2026-03-16 00:07:52 -04:00
|
|
|
carte_active = carte_token is not None
|
2026-03-14 22:00:16 -04:00
|
|
|
eligible = (
|
2026-03-14 02:03:44 -04:00
|
|
|
user.is_authenticated
|
|
|
|
|
and pending_slot is None
|
|
|
|
|
and user_reserved_slot is None
|
|
|
|
|
and user_filled_slot is None
|
2026-03-16 00:07:52 -04:00
|
|
|
and not carte_active
|
2026-03-14 02:03:44 -04:00
|
|
|
)
|
2026-03-14 22:00:16 -04:00
|
|
|
token_depleted = eligible and select_token(user) is None
|
|
|
|
|
can_drop = eligible and not token_depleted
|
2026-03-14 02:03:44 -04:00
|
|
|
is_last_slot = (
|
|
|
|
|
user_reserved_slot is not None
|
|
|
|
|
and slots.filter(status=GateSlot.EMPTY).count() == 0
|
|
|
|
|
)
|
2026-03-16 00:07:52 -04:00
|
|
|
user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
|
2026-03-14 02:03:44 -04:00
|
|
|
return {
|
|
|
|
|
"slots": slots,
|
|
|
|
|
"pending_slot": pending_slot,
|
|
|
|
|
"user_reserved_slot": user_reserved_slot,
|
|
|
|
|
"user_filled_slot": user_filled_slot,
|
|
|
|
|
"can_drop": can_drop,
|
2026-03-14 22:00:16 -04:00
|
|
|
"token_depleted": token_depleted,
|
2026-03-14 02:03:44 -04:00
|
|
|
"is_last_slot": is_last_slot,
|
|
|
|
|
"user_can_reject": user_can_reject,
|
2026-03-16 00:07:52 -04:00
|
|
|
"carte_active": carte_active,
|
|
|
|
|
"carte_slots_claimed": carte_slots_claimed,
|
|
|
|
|
"carte_nvm_slot_number": carte_nvm_slot_number,
|
2026-03-16 01:04:52 -04:00
|
|
|
"carte_next_slot_number": carte_next_slot_number,
|
2026-03-14 02:03:44 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
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
2026-03-17 00:24:23 -04:00
|
|
|
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"
|
2026-03-18 23:14:53 -04:00
|
|
|
starter_roles = list(
|
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
2026-03-17 00:24:23 -04:00
|
|
|
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,
|
2026-03-18 23:14:53 -04:00
|
|
|
"starter_roles": starter_roles,
|
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
2026-03-17 00:24:23 -04:00
|
|
|
"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
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
@login_required
|
|
|
|
|
def create_room(request):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
name = request.POST.get("name", "").strip()
|
|
|
|
|
if name:
|
|
|
|
|
room = Room.objects.create(name=name, owner=request.user)
|
|
|
|
|
return redirect("epic:gatekeeper", room_id=room.id)
|
2026-03-13 22:51:42 -04:00
|
|
|
return redirect("/gameboard/")
|
2026-03-13 00:31:17 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
def gatekeeper(request, room_id):
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
if room.table_status:
|
|
|
|
|
ctx = _role_select_context(room, request.user)
|
|
|
|
|
else:
|
|
|
|
|
ctx = _gate_context(room, request.user)
|
2026-03-14 02:03:44 -04:00
|
|
|
ctx["room"] = room
|
|
|
|
|
return render(request, "apps/gameboard/room.html", ctx)
|
|
|
|
|
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
@login_required
|
2026-03-14 02:03:44 -04:00
|
|
|
def drop_token(request, room_id):
|
2026-03-13 17:31:52 -04:00
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-15 01:17:09 -04:00
|
|
|
token_id = request.POST.get("token_id")
|
|
|
|
|
if token_id:
|
|
|
|
|
token = request.user.tokens.filter(id=token_id).first()
|
|
|
|
|
else:
|
|
|
|
|
token = select_token(request.user)
|
|
|
|
|
if token is None:
|
2026-03-14 22:00:16 -04:00
|
|
|
return HttpResponse(status=402)
|
2026-03-16 00:07:52 -04:00
|
|
|
if token.token_type == Token.CARTE:
|
|
|
|
|
# CARTE enters the machine without reserving a slot — all slots
|
|
|
|
|
# become individually claimable via .drop-token-btn
|
|
|
|
|
token.current_room = room
|
|
|
|
|
token.save()
|
|
|
|
|
request.session["kit_token_id"] = str(token.id)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
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)
|
|
|
|
|
if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
|
|
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
status=GateSlot.EMPTY
|
|
|
|
|
).order_by("slot_number").first()
|
|
|
|
|
if slot:
|
|
|
|
|
slot.gamer = request.user
|
|
|
|
|
slot.status = GateSlot.RESERVED
|
|
|
|
|
slot.reserved_at = timezone.now()
|
|
|
|
|
slot.save()
|
2026-03-15 01:17:09 -04:00
|
|
|
request.session["kit_token_id"] = str(token.id)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-13 17:31:52 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-13 18:37:19 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def confirm_token(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
slot_number = request.POST.get("slot_number")
|
|
|
|
|
if slot_number:
|
|
|
|
|
# CARTE per-slot fill: directly fill the requested slot
|
|
|
|
|
carte = request.user.tokens.filter(
|
|
|
|
|
token_type=Token.CARTE, current_room=room
|
|
|
|
|
).first()
|
|
|
|
|
if carte:
|
|
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
slot_number=slot_number, status=GateSlot.EMPTY
|
|
|
|
|
).first()
|
|
|
|
|
if slot:
|
|
|
|
|
debit_token(request.user, slot, carte)
|
|
|
|
|
# slots_claimed is the high-water mark — advance if beyond current
|
|
|
|
|
if int(slot_number) > carte.slots_claimed:
|
|
|
|
|
carte.slots_claimed = int(slot_number)
|
|
|
|
|
carte.save()
|
2026-03-19 15:48:59 -04:00
|
|
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
|
|
|
|
slot_number=int(slot_number), token_type=Token.CARTE,
|
|
|
|
|
token_display=carte.get_token_type_display(),
|
|
|
|
|
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
else:
|
|
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
gamer=request.user, status=GateSlot.RESERVED
|
|
|
|
|
).first()
|
|
|
|
|
if slot:
|
|
|
|
|
token_id = request.session.pop("kit_token_id", None)
|
|
|
|
|
token = None
|
|
|
|
|
if token_id:
|
|
|
|
|
token = request.user.tokens.filter(id=token_id).first()
|
|
|
|
|
if not token:
|
|
|
|
|
token = select_token(request.user)
|
|
|
|
|
if token:
|
|
|
|
|
debit_token(request.user, slot, token)
|
2026-03-19 15:48:59 -04:00
|
|
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
|
|
|
|
slot_number=slot.slot_number, token_type=token.token_type,
|
|
|
|
|
token_display=token.get_token_type_display(),
|
|
|
|
|
renewal_days=(room.renewal_period.days if room.renewal_period else 7))
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
2026-03-15 16:08:34 -04:00
|
|
|
def return_token(request, room_id):
|
2026-03-14 02:03:44 -04:00
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
# CARTE full return: reset token + all CARTE-debited slots
|
|
|
|
|
carte = request.user.tokens.filter(
|
|
|
|
|
token_type=Token.CARTE, current_room=room
|
|
|
|
|
).first()
|
|
|
|
|
if carte:
|
|
|
|
|
room.gate_slots.filter(
|
|
|
|
|
debited_token_type=Token.CARTE, gamer=request.user
|
|
|
|
|
).update(
|
|
|
|
|
gamer=None, status=GateSlot.EMPTY, filled_at=None,
|
|
|
|
|
debited_token_type=None, debited_token_expires_at=None,
|
|
|
|
|
)
|
|
|
|
|
carte.current_room = None
|
|
|
|
|
carte.slots_claimed = 0
|
|
|
|
|
carte.save()
|
|
|
|
|
request.session.pop("kit_token_id", None)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-16 00:07:52 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
gamer=request.user,
|
|
|
|
|
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
|
|
|
|
).first()
|
|
|
|
|
if slot:
|
2026-03-15 16:08:34 -04:00
|
|
|
if slot.status == GateSlot.FILLED:
|
|
|
|
|
if slot.debited_token_type == Token.COIN:
|
|
|
|
|
coin = request.user.tokens.filter(
|
|
|
|
|
token_type=Token.COIN, current_room=room
|
|
|
|
|
).first()
|
|
|
|
|
if coin:
|
|
|
|
|
coin.current_room = None
|
|
|
|
|
coin.next_ready_at = None
|
|
|
|
|
coin.save()
|
|
|
|
|
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
|
|
|
|
|
Token.objects.create(
|
|
|
|
|
user=request.user,
|
|
|
|
|
token_type=slot.debited_token_type,
|
|
|
|
|
expires_at=slot.debited_token_expires_at,
|
|
|
|
|
)
|
|
|
|
|
request.session.pop("kit_token_id", None)
|
2026-03-14 02:03:44 -04:00
|
|
|
slot.gamer = None
|
|
|
|
|
slot.status = GateSlot.EMPTY
|
|
|
|
|
slot.reserved_at = None
|
|
|
|
|
slot.filled_at = None
|
2026-03-15 16:08:34 -04:00
|
|
|
slot.debited_token_type = None
|
|
|
|
|
slot.debited_token_expires_at = None
|
2026-03-14 02:03:44 -04:00
|
|
|
slot.save()
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_gate_update(room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 00:07:52 -04:00
|
|
|
@login_required
|
|
|
|
|
def release_slot(request, room_id):
|
|
|
|
|
"""Un-fill a single CARTE-claimed slot without returning the CARTE itself."""
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
slot_number = request.POST.get("slot_number")
|
|
|
|
|
if slot_number:
|
|
|
|
|
slot = room.gate_slots.filter(
|
|
|
|
|
slot_number=slot_number,
|
|
|
|
|
debited_token_type=Token.CARTE,
|
|
|
|
|
gamer=request.user,
|
|
|
|
|
status=GateSlot.FILLED,
|
|
|
|
|
).first()
|
|
|
|
|
if slot:
|
|
|
|
|
slot.gamer = None
|
|
|
|
|
slot.status = GateSlot.EMPTY
|
|
|
|
|
slot.filled_at = None
|
|
|
|
|
slot.debited_token_type = None
|
|
|
|
|
slot.debited_token_expires_at = None
|
|
|
|
|
slot.save()
|
2026-03-16 01:04:52 -04:00
|
|
|
if room.gate_status == Room.OPEN:
|
|
|
|
|
room.gate_status = Room.GATHERING
|
|
|
|
|
room.save()
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_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)
|
|
|
|
|
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)
|
2026-03-21 14:33:06 -04:00
|
|
|
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()
|
2026-03-19 15:48:59 -04:00
|
|
|
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))
|
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
2026-03-17 00:24:23 -04:00
|
|
|
if room.table_seats.filter(role__isnull=True).exists():
|
|
|
|
|
_notify_turn_changed(room_id)
|
|
|
|
|
else:
|
|
|
|
|
room.table_status = Room.SIG_SELECT
|
|
|
|
|
room.save()
|
2026-03-19 15:48:59 -04:00
|
|
|
record(room, GameEvent.ROLES_REVEALED)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
_notify_roles_revealed(room_id)
|
2026-03-18 23:14:53 -04:00
|
|
|
return HttpResponse(status=200)
|
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
2026-03-17 00:24:23 -04:00
|
|
|
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)
|
2026-03-21 22:22:06 -04:00
|
|
|
if room.gate_status == Room.OPEN and room.table_status is None:
|
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
2026-03-17 00:24:23 -04:00
|
|
|
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)
|
2026-03-16 00:07:52 -04:00
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 18:37:19 -04:00
|
|
|
@login_required
|
|
|
|
|
def invite_gamer(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
email = request.POST.get("invitee_email", "").strip()
|
|
|
|
|
if email:
|
|
|
|
|
RoomInvite.objects.get_or_create(
|
|
|
|
|
room=room,
|
|
|
|
|
inviter=request.user,
|
|
|
|
|
invitee_email=email,
|
|
|
|
|
defaults={"status": RoomInvite.PENDING}
|
|
|
|
|
)
|
|
|
|
|
return redirect("epic:gatekeeper", room_id=room_id)
|
2026-03-13 22:51:42 -04:00
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-14 00:10:40 -04:00
|
|
|
@login_required
|
|
|
|
|
def delete_room(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
if request.user == room.owner:
|
|
|
|
|
room.delete()
|
|
|
|
|
return redirect("/gameboard/")
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-14 00:10:40 -04:00
|
|
|
@login_required
|
|
|
|
|
def abandon_room(request, room_id):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
room.gate_slots.filter(gamer=request.user).update(
|
|
|
|
|
gamer=None, status="EMPTY", filled_at=None
|
|
|
|
|
)
|
|
|
|
|
room.invites.filter(
|
|
|
|
|
invitee_email=request.user.email,
|
|
|
|
|
status=RoomInvite.PENDING
|
|
|
|
|
).delete()
|
|
|
|
|
return redirect("/gameboard/")
|
|
|
|
|
|
2026-03-14 02:03:44 -04:00
|
|
|
|
2026-03-13 22:51:42 -04:00
|
|
|
def gate_status(request, room_id):
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
2026-03-14 02:03:44 -04:00
|
|
|
ctx = _gate_context(room, request.user)
|
|
|
|
|
ctx["room"] = room
|
|
|
|
|
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
2026-03-24 21:07:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def tarot_deck(request, room_id):
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
deck_variant = request.user.equipped_deck
|
|
|
|
|
deck, _ = TarotDeck.objects.get_or_create(
|
|
|
|
|
room=room,
|
|
|
|
|
defaults={"deck_variant": deck_variant},
|
|
|
|
|
)
|
2026-03-24 22:25:25 -04:00
|
|
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
2026-03-24 21:07:01 -04:00
|
|
|
"room": room,
|
|
|
|
|
"deck": deck,
|
|
|
|
|
"remaining": deck.remaining_count,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required
|
|
|
|
|
def tarot_deal(request, room_id):
|
|
|
|
|
if request.method != "POST":
|
|
|
|
|
return redirect("epic:tarot_deck", room_id=room_id)
|
|
|
|
|
room = Room.objects.get(id=room_id)
|
|
|
|
|
deck = TarotDeck.objects.get(room=room)
|
|
|
|
|
drawn = deck.draw(6) # Celtic Cross: 6 cross positions; 4 staff filled via gameplay
|
|
|
|
|
positions = [
|
|
|
|
|
{
|
|
|
|
|
"card": card,
|
|
|
|
|
"reversed": is_reversed,
|
|
|
|
|
"orientation": "Reversed" if is_reversed else "Upright",
|
|
|
|
|
"position": i + 1,
|
|
|
|
|
}
|
|
|
|
|
for i, (card, is_reversed) in enumerate(drawn)
|
|
|
|
|
]
|
2026-03-24 22:25:25 -04:00
|
|
|
return render(request, "apps/gameboard/tarot_deck.html", {
|
2026-03-24 21:07:01 -04:00
|
|
|
"room": room,
|
|
|
|
|
"deck": deck,
|
|
|
|
|
"remaining": deck.remaining_count,
|
|
|
|
|
"positions": positions,
|
|
|
|
|
})
|
|
|
|
|
|