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 apps.epic.models import ( GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck, active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order, ) 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_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 _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}, ) 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, 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) ) _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, "starter_roles": starter_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, "gate_positions": _gate_positions(room), "slots": room.gate_slots.order_by("slot_number"), } 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") raw_sig_cards = sig_deck_cards(room) half = len(raw_sig_cards) // 2 ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]] ctx["sig_seats"] = sig_seat_order(room) ctx["sig_active_seat"] = active_sig_seat(room) 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: room.table_status = Room.SIG_SELECT room.save() record(room, GameEvent.ROLES_REVEALED) _notify_roles_revealed(room_id) return HttpResponse(status=200) 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 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, })