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 apps.epic.models import ( GateSlot, Room, RoomInvite, 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}, ) SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} _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"), } if room.table_status == Room.SIG_SELECT: user_seat = room.table_seats.filter(gamer=user).first() 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' ctx["user_seat"] = user_seat ctx["user_polarity"] = user_polarity ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve" # 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 = room.table_seats.filter(gamer=request.user).first() 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 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, 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 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, })