import json 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 from apps.drama.models import GameEvent, record from django.db.models import Case, IntegerField, Value, When from apps.epic.models import ( GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat, TarotCard, TarotDeck, active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, select_token, sig_deck_cards, ) 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 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)( f'room_{room_id}', {'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles}, ) def _notify_all_roles_filled(room_id): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'all_roles_filled'}, ) def _notify_sig_select_started(room_id): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'sig_select_started'}, ) 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 _notify_sig_selected(room_id, card_id, role, deck_type='levity'): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type}, ) _LEVITY_ROLES = {'PC', 'NC', 'SC'} _GRAVITY_ROLES = {'BC', 'EC', 'AC'} def _notify_sig_reserved(room_id, card_id, role, reserved): """Broadcast a sig_reserved event to the matching polarity cursor group.""" polarity = 'levity' if role in _LEVITY_ROLES else 'gravity' async_to_sync(get_channel_layer().group_send)( f'cursors_{room_id}_{polarity}', {'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None, 'role': role, 'reserved': reserved}, ) def _notify_countdown_start(room_id, polarity, *, seconds): async_to_sync(get_channel_layer().group_send)( f'cursors_{room_id}_{polarity}', {'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds}, ) def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining): async_to_sync(get_channel_layer().group_send)( f'cursors_{room_id}_{polarity}', {'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining}, ) def _notify_polarity_room_done(room_id, polarity): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'polarity_room_done', 'polarity': polarity}, ) def _notify_pick_sky_available(room_id): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'pick_sky_available'}, ) SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} _SIG_SEAT_ORDERING = Case( *[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)], default=Value(99), output_field=IntegerField(), ) def _canonical_user_seat(room, user): """Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order. In normal play (one user = one seat) this is equivalent to .first(). For Carte Blanche (one user = all seats) it returns the PC seat, ensuring sig-select cursor placement is seat-based, not position/slot-based. """ return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first() _ROLE_SCRAWL_NAMES = { "PC": "Player", "NC": "Narrator", "EC": "Economist", "SC": "Shepherd", "AC": "Alchemist", "BC": "Builder", } def _gate_positions(room): """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html.""" # Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless # of which role each gamer chose — so use count, not role matching. assigned_count = room.table_seats.exclude(role__isnull=True).count() return [ { "slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""), "role_assigned": slot.slot_number <= assigned_count, } for slot in room.gate_slots.order_by("slot_number") ] 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 carte_token = None carte_slots_claimed = 0 carte_nvm_slot_number = None carte_next_slot_number = None 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() 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 # 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 carte_active = carte_token is not None eligible = ( user.is_authenticated and pending_slot is None and user_reserved_slot is None and user_filled_slot is None and not carte_active ) token_depleted = eligible and select_token(user) is None can_drop = eligible and not token_depleted is_last_slot = ( user_reserved_slot is not None and slots.filter(status=GateSlot.EMPTY).count() == 0 ) user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active return { "slots": slots, "pending_slot": pending_slot, "user_reserved_slot": user_reserved_slot, "user_filled_slot": user_filled_slot, "can_drop": can_drop, "token_depleted": token_depleted, "is_last_slot": is_last_slot, "user_can_reject": user_can_reject, "carte_active": carte_active, "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), "starter_roles": [], } 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" starter_roles = list( room.table_seats.exclude(role__isnull=True).values_list("role", flat=True) ) if len(starter_roles) == 6: card_stack_state = None _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 _my_role = assigned_seats[0].role if assigned_seats else None ctx = { "card_stack_state": card_stack_state, "starter_roles": starter_roles, "assigned_seats": assigned_seats, "my_tray_role": _my_role, "my_tray_scrawl_static_path": ( f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg" if _my_role else None ), "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, "gate_positions": _gate_positions(room), "slots": room.gate_slots.order_by("slot_number"), } # Tray cell 2: sig card (set once polarity group confirms) _canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None if room.table_status == Room.SIG_SELECT: user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None user_role = user_seat.role if user_seat else None user_polarity = None if user_role in _LEVITY_ROLES: user_polarity = 'levity' elif user_role in _GRAVITY_ROLES: user_polarity = 'gravity' user_reservation = SigReservation.objects.filter( room=room, gamer=user ).first() if user.is_authenticated else None ctx["user_seat"] = user_seat ctx["user_polarity"] = user_polarity ctx["user_ready"] = bool(user_reservation and user_reservation.ready) ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve" # Has this gamer's polarity already had significators assigned? # (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.) if user_polarity: _polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES ctx["polarity_done"] = not room.table_seats.filter( role__in=_polarity_roles, significator__isnull=True ).exists() else: ctx["polarity_done"] = False # Pre-load existing reservations for this polarity so JS can restore # grabbed state on page load/refresh. Keyed by str(card_id) → role. if user_polarity: polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY reservations = { str(res.card_id): res.role for res in room.sig_reservations.filter(polarity=polarity_const) } else: reservations = {} ctx["sig_reservations_json"] = json.dumps(reservations) if user_polarity == 'levity': ctx["sig_cards"] = levity_sig_cards(room) elif user_polarity == 'gravity': ctx["sig_cards"] = gravity_sig_cards(room) else: ctx["sig_cards"] = [] return ctx @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) return redirect("/gameboard/") def gatekeeper(request, room_id): room = Room.objects.get(id=room_id) if room.table_status: 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) @login_required def drop_token(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) 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: return HttpResponse(status=402) 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) _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) if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists(): return redirect("epic:gatekeeper", room_id=room_id) 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() request.session["kit_token_id"] = str(token.id) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @login_required def confirm_token(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) 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() 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)) _notify_gate_update(room_id) 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) 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)) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @login_required def return_token(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) # 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) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) slot = room.gate_slots.filter( gamer=request.user, status__in=[GateSlot.RESERVED, GateSlot.FILLED], ).first() if slot: 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) slot.gamer = None slot.status = GateSlot.EMPTY slot.reserved_at = None slot.filled_at = None 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) @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() 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: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: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:room", 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)) if room.table_seats.filter(role__isnull=True).exists(): _notify_turn_changed(room_id) else: _notify_all_roles_filled(room_id) return HttpResponse(status=200) return redirect("epic:room", room_id=room_id) @login_required def pick_sigs(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) if room.table_status == Room.ROLE_SELECT: room.table_status = Room.SIG_SELECT room.save() _notify_sig_select_started(room_id) return redirect("epic:room", 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 and room.table_status is None: 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:room", room_id=room_id) @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) @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/") @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/") def gate_status(request, room_id): room = Room.objects.get(id=room_id) ctx = _gate_context(room, request.user) ctx["room"] = room return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) @login_required def sig_reserve(request, room_id): """Provisional card hold (OK / NVM) during SIG_SELECT. POST body: card_id=, action=reserve|release """ if request.method != "POST": return HttpResponse(status=405) room = Room.objects.get(id=room_id) if room.table_status != Room.SIG_SELECT: return HttpResponse(status=400) user_seat = _canonical_user_seat(room, request.user) if not user_seat or not user_seat.role: return HttpResponse(status=403) action = request.POST.get("action", "reserve") if action == "release": existing = SigReservation.objects.filter(room=room, gamer=request.user).first() released_card_id = existing.card_id if existing else None if existing and existing.ready: # Gamer released while ready — treat as an implicit WAIT NVM prior = room.events.filter( actor=request.user, verb=GameEvent.SIG_READY ).last() if prior and not prior.data.get("retracted"): prior.data["retracted"] = True prior.save(update_fields=["data"]) record(room, GameEvent.SIG_UNREADY, actor=request.user) polarity = existing.polarity all_ready = SigReservation.objects.filter( room=room, polarity=polarity, ready=True ).count() == 3 if all_ready: _notify_countdown_cancel(room_id, polarity, seconds_remaining=12) SigReservation.objects.filter(room=room, gamer=request.user).delete() _notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False) return HttpResponse(status=200) # Reserve action card_id = request.POST.get("card_id") try: card = TarotCard.objects.get(pk=card_id) except TarotCard.DoesNotExist: return HttpResponse(status=400) polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY # Block if another gamer in the same polarity already holds this card if SigReservation.objects.filter( room=room, card=card, polarity=polarity ).exclude(gamer=request.user).exists(): return HttpResponse(status=409) # Block if this gamer already holds a *different* card — must NVM first existing = SigReservation.objects.filter(room=room, gamer=request.user).first() if existing and existing.card != card: return HttpResponse(status=409) # Idempotent: already holding the same card if existing: return HttpResponse(status=200) SigReservation.objects.create( room=room, gamer=request.user, card=card, seat=user_seat, role=user_seat.role, polarity=polarity, ) _notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True) return HttpResponse(status=200) @login_required def sig_ready(request, room_id): """Toggle ready/unready for the polarity-room countdown. POST body: action=ready|unready [, seconds_remaining=] """ if request.method != "POST": return HttpResponse(status=405) room = Room.objects.get(id=room_id) if room.table_status != Room.SIG_SELECT: return HttpResponse(status=400) user_seat = _canonical_user_seat(room, request.user) if user_seat is None: return HttpResponse(status=403) action = request.POST.get("action", "ready") reservation = SigReservation.objects.filter(room=room, gamer=request.user).first() if action == "ready": if reservation is None: return HttpResponse(status=400) if reservation.ready: return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown reservation.ready = True reservation.save(update_fields=["ready"]) card = reservation.card if card and card.arcana == TarotCard.MIDDLE: _pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven" _card_display = f"{_pol_prefix} {card.name_title}" elif card and card.arcana == TarotCard.MAJOR: _base = card.name_title.removeprefix("The ") _pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave" _card_display = f"{_base} {_pol_suffix}" else: _card_display = card.name_title if card else "a card" record(room, GameEvent.SIG_READY, actor=request.user, card_name=_card_display, corner_rank=card.corner_rank if card else "", suit_icon=card.suit_icon if card else "") # Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot) prior_unready = room.events.filter( actor=request.user, verb=GameEvent.SIG_UNREADY ).last() if prior_unready and not prior_unready.data.get("retracted"): prior_unready.data["retracted"] = True prior_unready.save(update_fields=["data"]) # Check if all three in this polarity are now ready polarity = reservation.polarity polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES ready_count = SigReservation.objects.filter( room=room, polarity=polarity, ready=True ).count() if ready_count == 3: from apps.epic.tasks import schedule_polarity_confirm # Use saved countdown_remaining if a pause was recorded, else 12 saved = SigReservation.objects.filter( room=room, polarity=polarity ).exclude(countdown_remaining__isnull=True).values_list( "countdown_remaining", flat=True ).first() seconds = saved if saved is not None else 12 schedule_polarity_confirm(str(room_id), polarity, seconds) _notify_countdown_start(room_id, polarity, seconds=seconds) else: # unready if reservation is not None: reservation.ready = False reservation.save(update_fields=["ready"]) # Mark the most recent un-retracted SIG_READY event for this actor prior = room.events.filter( actor=request.user, verb=GameEvent.SIG_READY ).last() if prior and not prior.data.get("retracted"): prior.data["retracted"] = True prior.save(update_fields=["data"]) record(room, GameEvent.SIG_UNREADY, actor=request.user) polarity = reservation.polarity # Save remaining seconds on all polarity reservations try: seconds_remaining = int(request.POST.get("seconds_remaining", 12)) except (TypeError, ValueError): seconds_remaining = 12 SigReservation.objects.filter(room=room, polarity=polarity).update( countdown_remaining=seconds_remaining ) _notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining) from apps.epic.tasks import cancel_polarity_confirm cancel_polarity_confirm(str(room_id), polarity) return HttpResponse(status=200) @login_required def sig_confirm(request, room_id): """No-op: polarity confirmation is now driven server-side by threading.Timer in tasks.py.""" return HttpResponse(status=200) @login_required def select_sig(request, room_id): if request.method != "POST": 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: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) card_id = request.POST.get("card_id") try: card = TarotCard.objects.get(pk=card_id) except TarotCard.DoesNotExist: return HttpResponse(status=400) sig_card_ids = {c.pk for c in sig_deck_cards(room)} if card.pk not in sig_card_ids: return HttpResponse(status=400) if room.table_seats.filter(significator=card).exists(): return HttpResponse(status=409) active_seat.significator = card active_seat.save() deck_type = request.POST.get('deck_type', 'levity') _notify_sig_selected(room_id, card.pk, active_seat.role, deck_type) return HttpResponse(status=200) @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}, ) return render(request, "apps/gameboard/tarot_deck.html", { "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) ] return render(request, "apps/gameboard/tarot_deck.html", { "room": room, "deck": deck, "remaining": deck.remaining_count, "positions": positions, })