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.urls import reverse from django.template.loader import render_to_string from django.utils import timezone from apps.billboard.forms import ExistingPostLineForm 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, card_dict, stack_reversal_probability, top_capacitors from apps.lyric.models import Token RESERVE_TIMEOUT = timedelta(seconds=60) def _retract_prior_event(room, actor, verbs, slot_number=None): """Mark the most-recent unretracted GameEvent for `actor` on `room` matching one of `verbs` (and optional `slot_number`) as retracted. Drives the symmetric redact-pair pattern in the room scroll: every state transition (deposit ↔ withdraw, sig-ready ↔ sig-unready) sets `data.retracted=True` on its counterpart's prior entry, which the scroll template renders strikethrough + Redact-tagged. `verbs` is a list/tuple — e.g. when a deposit lands, retract the prior SLOT_RETURNED *or* SLOT_RELEASED for the same slot (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Sprint A.8 user-spec 2026-05-26.""" qs = room.events.filter(actor=actor, verb__in=verbs) if slot_number is not None: qs = qs.filter(data__slot_number=slot_number) prior = qs.last() if prior and not prior.data.get("retracted"): prior.data["retracted"] = True prior.save(update_fields=["data"]) 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"} # Sea Select affinity — each Role is correlated with one Celtic-Cross position # (the "universal slot term"), keyed by the sixfold chair index (user-spec # 2026-06-08; roles rotate each round, but a seat's CURRENT role drives this). # The card drawn into that position is the gamer's affinity (the SEA_DRAWN # Scroll log). All spread label-sets (incl. Waite-Smith's Behind/Before/Beneath) # key to the SAME position keys — only the displayed label differs per spread. ROLE_POSITION_MAP = { "PC": "crown", "NC": "leave", "EC": "loom", "SC": "cover", "AC": "cross", "BC": "lay", } # Per-spread display label for each position key (mirrors POSITION_LABELS in # _sea_overlay.html). The position KEY is the canonical index; the label is what # that spread calls it. SEA_POSITION_LABELS = { "waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover", "cross": "Cross", "loom": "Before", "lay": "Beneath"}, "escape-velocity": {"crown": "Crown", "leave": "Leave", "cover": "Cover", "cross": "Cross", "loom": "Loom", "lay": "Lay"}, } _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() def _acting_seat(room, user, seat_param): """The seat an action targets: the `?seat=N` override when N is one of the user's owned slots, else the canonical seat. Lets a CARTE multi-seat owner act per-seat — a distinct sig reservation in SIG_SELECT, a distinct sky/sea Character in SKY_SELECT — across all six owned seats.""" seat = _canonical_user_seat(room, user) if seat_param: try: n = int(seat_param) except (TypeError, ValueError): n = None if n is not None: override = room.table_seats.filter(gamer=user, slot_number=n).first() if override: seat = override return seat _ROLE_SCRAWL_NAMES = { "PC": "Player", "NC": "Narrator", "EC": "Economist", "SC": "Shepherd", "AC": "Alchemist", "BC": "Builder", } def _viewer_current_slot(room, user, seat_param=None): """The slot the viewer is currently "acting as": a ?seat=N override when they own that slot, else their lowest-numbered owned slot (the canonical seat). None for anon / non-seated viewers. Splits the viewer's own position circles into me-current (this slot) vs me-also (their other CARTE-claimed slots).""" if not getattr(user, "is_authenticated", False): return None owned = sorted( room.gate_slots.filter(gamer=user).values_list("slot_number", flat=True) ) if not owned: return None if seat_param: try: n = int(seat_param) except (TypeError, ValueError): n = None if n in owned: return n return owned[0] def _gate_positions(room, user=None, current_slot=None): """Return list of per-circle dicts for _table_positions.html. Carries the legacy keys (slot, role_label, role_assigned) PLUS the rich tooltip payload (sprint 2026-06-02): a `.tt-pos-*` state class classifying the occupant relative to `user`, the deposited-token count, the seat-clock expiry, the seat significator rank/suit, and bud shoptalk. `@handle` + title are read off `pos.slot.gamer` in the template (at_handle / active_title_display). `current_slot` is the viewer's acting seat (`_viewer_current_slot`) — it splits the viewer's own circles me-current vs me-also.""" # 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() seats_by_slot = { s.slot_number: s for s in room.table_seats.select_related("significator").all() } authed = getattr(user, "is_authenticated", False) bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set() shoptalk_map = {} if authed: from apps.billboard.models import BudshipNote shoptalk_map = { bn.bud_id: bn.shoptalk for bn in BudshipNote.objects.filter(user=user) } # value → display ("carte" → "Carte Blanche", "Free" → "Free Token") for # the per-slot deposited-token `