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, 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"} _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 # `equipped_deck_id` gates the JS role-select guard (role-select.js L165). # Falls back to ANY of the user's seats in this room w. deck_variant set # — covers the CARTE multi-seat return path where the first role-pick # cleared `user.equipped_deck` (the deck is committed to seats, but still # "in play"). Without this, a CARTE user navigating away + back gets # "Equip card deck before Role select" wrongly fired. See [[sprint-carte- # role-select-return-may18]] / commit-TBD for the bug trail. role_select_deck_id = ( user.equipped_deck_id if user.is_authenticated else None ) if user.is_authenticated and not role_select_deck_id: seat_w_deck = room.table_seats.filter( gamer=user, deck_variant__isnull=False, ).order_by("slot_number").first() if seat_w_deck: role_select_deck_id = seat_w_deck.deck_variant_id ctx = { "card_stack_state": card_stack_state, "equipped_deck_id": role_select_deck_id, "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"), } # Viewer's seat token-cost state — drives the center-hex GATE VIEW # supersession (room.html). When the viewer's FILLED slot's cost has # lapsed (filled_at past the cost-current window), GATE VIEW replaces # SCAN SIGS / CAST SKY / DRAW SEA / the sig overlay; the gamer's own # ROLE card-stack pick survives the renewal grace. No filled slot → # treated as current (defensive — non-seated viewers see the normal UI). viewer_slot = ( room.gate_slots.filter(gamer=user, status=GateSlot.FILLED).first() if user.is_authenticated else None ) ctx["viewer_cost_current"] = viewer_slot.cost_current if viewer_slot else True ctx["viewer_in_grace"] = viewer_slot.in_renewal_grace if viewer_slot else False # 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) # System-authored welcome (actor=None) — first scroll log # on every room. Renders via GameEvent.to_prose as # "Welcome to !" with no actor prefix. record(room, GameEvent.ROOM_CREATED) 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 # `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's # `page-my-sea`) so the table page reaches the renewal gate-view instead # of a self-referential CONT GAME. The bare gameboard listing stays # `page-gameboard` (no page-room) → keeps CONT GAME. ctx["page_class"] = "page-gameboard page-room" # Reversal-rate hint label under DRAW 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 room_gate(request, room_id): """Room renewal gate-view — reachable mid-game (unlike `gatekeeper`, which redirects to the table once `table_status` is set). GATE VIEW (navbar + center supersession) routes here. Reuses the gatekeeper's token-slot modal: when the viewer's seat cost is current the roles panel shows CONT GAME (→ table hex, same target as the gear NVM) and the status reads " Token(s) Deposited"; when the cost has lapsed the rails go active to RENEW and the status reads "Please Deposit Token" (no CONT GAME until the cost is satisfied again). The seat circle + time-remaining live on the table hex / next-sprint user-seat tooltips, so they're intentionally absent here (user-spec 2026-05-31).""" room = Room.objects.get(id=room_id) user_slot = room.gate_slots.filter( gamer=request.user, status=GateSlot.FILLED ).first() return render(request, "apps/gameboard/room_gate.html", { "room": room, "cost_current": user_slot.cost_current if user_slot else True, "deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(), "page_class": "page-gameboard page-room page-room-gate", }) @login_required def renew_token(request, room_id): """Renew the viewer's seat — re-deposit a token into their already-FILLED slot, resetting `filled_at=now` (via `debit_token`) so the cost-current window restarts. Distinct from `confirm_token` (which transitions a RESERVED slot); renewal is a FILLED→FILLED refresh of an occupied seat. 402 when the user is token-depleted; no-op redirect when the user holds no filled slot (e.g. already auto-BYE'd out of the room).""" if request.method != "POST": return redirect("epic:room_gate", room_id=room_id) room = Room.objects.get(id=room_id) slot = room.gate_slots.filter( gamer=request.user, status=GateSlot.FILLED ).first() if slot is None: return redirect("epic:room_gate", room_id=room_id) token_id = request.POST.get("token_id") token = (request.user.tokens.filter(id=token_id).first() if token_id else select_token(request.user)) if token is None: return HttpResponse(status=402) debit_token(request.user, slot, token) # resets filled_at=now → A=now 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:room_gate", room_id=room_id) @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() # Redact-pair: a re-deposit on this slot strikes the most- # recent unretracted withdraw entry for this slot (user- # spec 2026-05-26 — symmetric mirror of the sig embody/ # disembody pattern). _retract_prior_event( room, request.user, (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED), slot_number=int(slot_number), ) 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) # Redact-pair: re-deposit on this slot strikes the prior # unretracted withdraw entry for this slot (sprint A.8). _retract_prior_event( room, request.user, (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED), slot_number=slot.slot_number, ) 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. Snapshot # the slot numbers BEFORE the bulk update so we can emit a per-slot # withdraw + redact pair (one entry per slot was deposited, so one # entry per slot is withdrawn — symmetric mirror per user-spec # 2026-05-26 sprint A.8). carte = request.user.tokens.filter( token_type=Token.CARTE, current_room=room ).first() if carte: carte_slot_numbers = list( room.gate_slots.filter( debited_token_type=Token.CARTE, gamer=request.user, ).values_list("slot_number", flat=True) ) 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) for n in carte_slot_numbers: _retract_prior_event( room, request.user, (GameEvent.SLOT_FILLED,), slot_number=n, ) record(room, GameEvent.SLOT_RETURNED, actor=request.user, slot_number=n, token_type=Token.CARTE, token_display=carte.get_token_type_display()) _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: # Snapshot token-type + slot-number BEFORE the slot reset so the # log entry carries the right payload. withdraw_token_type = slot.debited_token_type withdraw_slot_number = slot.slot_number 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) was_filled = slot.status == GateSlot.FILLED 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() # Only emit a withdraw entry when a deposit was actually undone # (FILLED → EMPTY). RESERVED → EMPTY is a pre-confirm cancel # that never recorded a SLOT_FILLED, so no redact-pair fires. if was_filled and withdraw_token_type: _retract_prior_event( room, request.user, (GameEvent.SLOT_FILLED,), slot_number=withdraw_slot_number, ) token_display = dict(Token.TOKEN_TYPE_CHOICES).get( withdraw_token_type, withdraw_token_type, ) record(room, GameEvent.SLOT_RETURNED, actor=request.user, slot_number=withdraw_slot_number, token_type=withdraw_token_type, token_display=token_display) _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. Emits a SLOT_RELEASED event (renders w. the unified withdraw prose, shape-matched to the deposit) and retracts the corresponding prior SLOT_FILLED so the room scroll renders the redact-pair per user-spec 2026-05-26 (sprint A.8). """ 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: released_slot_number = slot.slot_number 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() _retract_prior_event( room, request.user, (GameEvent.SLOT_FILLED,), slot_number=released_slot_number, ) record(room, GameEvent.SLOT_RELEASED, actor=request.user, slot_number=released_slot_number, token_type=Token.CARTE, token_display=dict(Token.TOKEN_TYPE_CHOICES).get( Token.CARTE, "Carte Blanche")) _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 CAST 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 DRAW 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) 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, reversal_prob) for c in available[:mid]], 'gravity': [card_dict(c, reversal_prob) 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)