import json import zoneinfo from datetime import datetime, timedelta import requests as http_requests from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction from django.http import HttpResponse, HttpResponseForbidden, JsonResponse 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 ( Character, 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.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability, top_capacitors 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'}, ) def _notify_sky_confirmed(room_id, seat_role): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', {'type': 'sky_confirmed', 'seat_role': seat_role}, ) 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, "equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None, "starter_roles": starter_roles, "assigned_seats": assigned_seats, "my_tray_role": _my_role, "my_tray_role_tooltip": ( { "title": _ROLE_SCRAWL_NAMES.get(_my_role, ""), "description": "[Placeholder description]", } if _my_role else None ), "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, user) elif user_polarity == 'gravity': ctx["sig_cards"] = gravity_sig_cards(room, user) else: ctx["sig_cards"] = [] if room.table_status == Room.SKY_SELECT: user_role = _canonical_seat.role if _canonical_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_polarity"] = user_polarity confirmed_char = ( Character.objects.filter( seat=_canonical_seat, confirmed_at__isnull=False, retired_at__isnull=True, ).first() if _canonical_seat else None ) sky_confirmed = confirmed_char is not None ctx["sky_confirmed"] = sky_confirmed ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else '' if sky_confirmed: # Fall back to seat.significator for Characters created before the sync was added ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator 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" # Reversal-rate hint label under PICK SEA's SPREAD select — same helper as # sea_partial so the value tracks any future per-user override automatically. ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100)) 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 if token.current_room_id and token.current_room_id != room.id: return HttpResponse(status=409) token.current_room = room token.save() if request.user.equipped_trinket_id == token.pk: request.user.equipped_trinket = None request.user.save(update_fields=["equipped_trinket"]) 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) existing = None 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 existing = room.table_seats.filter( gamer=request.user, deck_variant__isnull=False, ).exclude(pk=active_seat.pk).order_by("slot_number").first() active_seat.deck_variant = ( existing.deck_variant if existing else request.user.equipped_deck ) active_seat.save() if not existing and request.user.equipped_deck: request.user.equipped_deck = None request.user.save(update_fields=["equipped_deck"]) 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): """Gatekeeper invite flow. Backwards-compatible w. the legacy `invitee_email` form-submit (still POSTs from any old caller); also serves the new bud-btn slide-out which sends `recipient` (email OR username) + Accept: application/json. Bud-btn flow: • Resolves recipient via _resolve_recipient (registered → User; else None). • Stores RoomInvite using the resolved email (or raw input if unregistered). • Auto-adds inviter ↔ recipient to each others' buds (symmetric, per share_post precedent — registered recipients only). • Spawns a Brief w. kind=GAME_INVITE + room=room (post=null). • Returns JSON {brief, recipient_display} when Accept matches; else redirects to gatekeeper as before.""" if request.method != "POST": return redirect("epic:gatekeeper", room_id=room_id) from apps.billboard.models import Brief from apps.billboard.views import _resolve_recipient room = Room.objects.get(id=room_id) is_ajax = "application/json" in request.headers.get("Accept", "") # New bud-btn field name is `recipient`; legacy form uses `invitee_email`. raw = ( request.POST.get("recipient") or request.POST.get("invitee_email") or "" ).strip() if not raw: if is_ajax: return JsonResponse({"brief": None, "recipient_display": None}) return redirect("epic:gatekeeper", room_id=room_id) candidate = _resolve_recipient(raw) is_self = candidate is not None and candidate == request.user if is_self: if is_ajax: return JsonResponse({"brief": None, "recipient_display": None}) return redirect("epic:gatekeeper", room_id=room_id) # RoomInvite uses the resolved User's email when available (so a # username-typed invite doesn't store the raw username as if it were # an email); falls back to the raw input for unregistered addresses. invitee_email = candidate.email if candidate else raw # Duplicate-invite guard: "already present" = recipient is either # already seated in the room OR has a (pending/accepted) RoomInvite. # During gatekeeper phase the visible `.gate-slot.filled` cells are # GateSlot-driven (TableSeats spin up later at SIG SELECT), so check # both — GateSlot.FILLED catches the in-phase case, TableSeat catches # the post-phase case. Seated recipients carry recipient_user_id so # the client can find the .gate-slot.filled[data-user-id="X"] # highlight target; pending invitees have no visible slot, so # recipient_user_id stays null. already_seated = candidate is not None and ( GateSlot.objects.filter( room=room, gamer=candidate, status=GateSlot.FILLED, ).exists() or TableSeat.objects.filter(room=room, gamer=candidate).exists() ) already_invited = RoomInvite.objects.filter( room=room, invitee_email=invitee_email, ).exists() already_present = already_seated or already_invited brief = None if not already_present: RoomInvite.objects.create( room=room, inviter=request.user, invitee_email=invitee_email, status=RoomInvite.PENDING, ) # Buds graph: symmetric auto-add on registered recipients (mirrors # share_post). Idempotent on M2M; no-op on unregistered recipients. if candidate is not None: request.user.buds.add(candidate) candidate.buds.add(request.user) # Brief: confirmation banner for the inviter. Brief.post stays # null; banner FYI navigates to the room's gatekeeper page via # Brief.room. brief = Brief.objects.create( owner=request.user, post=None, room=room, kind=Brief.KIND_GAME_INVITE, title="Invite sent", ) recipient_user_id = str(candidate.id) if already_seated else None if is_ajax: recipient_display = None if candidate is not None: recipient_display = candidate.username or candidate.email return JsonResponse({ "brief": brief.to_banner_dict() if brief is not None else None, "recipient_display": recipient_display, "recipient_user_id": recipient_user_id, "already_present": already_present, }) 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: _qual = card.levity_qualifier if reservation.polarity == SigReservation.LEVITY else card.gravity_qualifier _card_display = f"{_qual} {card.name_title}" if _qual else card.name_title else: _card_display = "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): """Finalise polarity group once the countdown fires. POST body: polarity=levity|gravity """ 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) polarity = request.POST.get("polarity", SigReservation.LEVITY) polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES # Idempotency: seats already have significators if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists(): return HttpResponse(status=200) # All three in the polarity group must be ready ready_count = SigReservation.objects.filter(room=room, polarity=polarity, ready=True).count() if ready_count < 3: return HttpResponse(status=400) # Assign significators from reservations reservations = list( SigReservation.objects.filter(room=room, polarity=polarity, ready=True) .select_related('seat', 'card') ) for res in reservations: if res.seat: res.seat.significator = res.card res.seat.save(update_fields=['significator']) SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None) _notify_polarity_room_done(room_id, polarity) # If both polarities are now done, advance to SKY_SELECT if not room.table_seats.filter(significator__isnull=True).exists(): Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT) _notify_pick_sky_available(room_id) 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, }) # ── Sky (natal chart) ─────────────────────────────────────────────────────── @login_required def sky_preview(request, room_id): """Proxy GET to PySwiss /api/chart/ and augment with distinction counts. Query params: date — YYYY-MM-DD (local birth date) time — HH:MM (local birth time, default 12:00) tz — IANA timezone string (optional; auto-resolved from lat/lon if absent) lat — float lon — float If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the coordinates before converting the local datetime to UTC. Response includes a 'timezone' key (resolved or supplied) so the client can back-fill the timezone field after the first wheel render. No database writes — safe for debounced real-time calls. """ seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user) if seat is None: return HttpResponse(status=403) date_str = request.GET.get('date') time_str = request.GET.get('time', '12:00') tz_str = request.GET.get('tz', '').strip() lat_str = request.GET.get('lat') lon_str = request.GET.get('lon') if not date_str or lat_str is None or lon_str is None: return HttpResponse(status=400) try: lat = float(lat_str) lon = float(lon_str) except ValueError: return HttpResponse(status=400) if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): return HttpResponse(status=400) # Resolve timezone from coordinates if not supplied if not tz_str: try: tz_resp = http_requests.get( settings.PYSWISS_URL + '/api/tz/', params={'lat': lat_str, 'lon': lon_str}, timeout=5, ) tz_resp.raise_for_status() tz_str = tz_resp.json().get('timezone') or 'UTC' except Exception: tz_str = 'UTC' try: tz = zoneinfo.ZoneInfo(tz_str) except (zoneinfo.ZoneInfoNotFoundError, KeyError): return HttpResponse(status=400) try: local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M') local_dt = local_dt.replace(tzinfo=tz) utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC')) dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ') except ValueError: return HttpResponse(status=400) try: resp = http_requests.get( settings.PYSWISS_URL + '/api/chart/', params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str}, timeout=5, ) resp.raise_for_status() except Exception: return HttpResponse(status=502) data = resp.json() # PySwiss uses "Earth"; the wheel and SCSS use "Stone". if 'elements' in data and 'Earth' in data['elements']: data['elements']['Stone'] = data['elements'].pop('Earth') data['distinctions'] = _compute_distinctions(data['planets'], data['houses']) data['timezone'] = tz_str return JsonResponse(data) @login_required def sky_save(request, room_id): """Create or update the draft Character for the requesting gamer's seat. POST body (JSON): birth_dt — ISO 8601 UTC datetime birth_lat — float birth_lon — float birth_place — display string (optional) house_system — single char, default 'O' chart_data — full PySwiss response dict (incl. distinctions) action — 'save' (default) or 'confirm' On 'confirm': sets confirmed_at, locking the Character. Returns: {id, confirmed} """ if request.method != 'POST': return HttpResponse(status=405) room = Room.objects.get(id=room_id) seat = _canonical_user_seat(room, request.user) if seat is None: return HttpResponse(status=403) try: body = json.loads(request.body) except json.JSONDecodeError: return HttpResponse(status=400) # Find or create the active draft (unconfirmed, unretired) for this seat char = Character.objects.filter( seat=seat, confirmed_at__isnull=True, retired_at__isnull=True, ).first() if char is None: char = Character(seat=seat) char.birth_dt = body.get('birth_dt') char.birth_lat = body.get('birth_lat') char.birth_lon = body.get('birth_lon') char.birth_place = body.get('birth_place', '') char.house_system = body.get('house_system', Character.PORPHYRY) char.chart_data = body.get('chart_data') char.significator = seat.significator if body.get('action') == 'confirm': char.confirmed_at = timezone.now() char.save() if char.is_confirmed: from apps.drama.models import GameEvent, record caps = top_capacitors((char.chart_data or {}).get('elements')) record( room, GameEvent.SKY_SAVED, actor=request.user, top_capacitors=caps, ) _notify_sky_confirmed(room_id, seat.role) return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) @login_required def sky_delete(request, room_id): """Purge the requesting gamer's Character on this seat — both unconfirmed drafts AND confirmed rows. The in-room PICK SKY DEL targets this so SAVE SKY → DEL → refresh truly drops the saved sky for the seat. The User model's sky_chart_data is intentionally untouched (Dashsky / My Sky applet's DEL handles that separately).""" if request.method != 'POST': return HttpResponse(status=405) room = Room.objects.get(id=room_id) seat = _canonical_user_seat(room, request.user) if seat is None: return HttpResponseForbidden() Character.objects.filter(seat=seat, retired_at__isnull=True).delete() return JsonResponse({'deleted': True}) @login_required def sea_deck(request, room_id): """Shuffled deck lists (levity + gravity halves) for PICK SEA draw. Excludes all Significators already claimed by seated gamers. Returns {levity: [{id, name, arcana, suit, number, levity_qualifier, gravity_qualifier}], gravity: [...]} """ import random as _random room = Room.objects.get(id=room_id) seat = _canonical_user_seat(room, request.user) if seat is None: return HttpResponse(status=403) deck = seat.deck_variant if not deck: return JsonResponse({'levity': [], 'gravity': []}) sig_ids = set( room.table_seats.exclude(significator__isnull=True) .values_list('significator_id', flat=True) ) # Roll reversal eagerly during the shuffle — the deck order is fully # determined at phase start, so the reversal axis should be too. Future # per-user-profile config rides this same helper. reversal_prob = stack_reversal_probability(request.user, room) def _card_dict(c): return { 'id': c.id, 'name': c.name, 'arcana': c.arcana, 'suit': c.suit, 'number': c.number, 'corner_rank': c.corner_rank, 'suit_icon': c.suit_icon, 'name_group': c.name_group, 'name_title': c.name_title, 'levity_qualifier': c.levity_qualifier, 'gravity_qualifier': c.gravity_qualifier, 'reversal_qualifier': c.reversal_qualifier, # Polarity-split full-title overrides (cards 48-49 + trumps 19-21) 'levity_emanation': c.levity_emanation, 'gravity_emanation': c.gravity_emanation, 'levity_reversal': c.levity_reversal, 'gravity_reversal': c.gravity_reversal, # Word inside any title slot to wrap in at render time 'italic_word': c.italic_word, 'keywords_upright': c.keywords_upright, 'keywords_reversed': c.keywords_reversed, 'energies': c.energies, 'operations': c.operations, # Pre-rolled reversal axis — server-deterministic, client just reads 'reversed': _random.random() < reversal_prob, } available = list( TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids) ) _random.shuffle(available) mid = len(available) // 2 return JsonResponse({ 'levity': [_card_dict(c) for c in available[:mid]], 'gravity': [_card_dict(c) for c in available[mid:]], }) @login_required def sea_partial(request, room_id): """Return the rendered sea overlay partial for in-page injection after sky confirm.""" room = Room.objects.get(id=room_id) ctx = _role_select_context(room, request.user) if not ctx.get('sky_confirmed'): return HttpResponse(status=403) ctx['room'] = room # Reversal-rate hint label under SPREAD — both the percentage AND the raw # probability flow from the same helper, so when per-user config lands we # only swap the helper body and every render picks it up. _prob = stack_reversal_probability(request.user, room) ctx['stack_reversal_pct'] = int(round(_prob * 100)) return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)