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"}
_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_sig_seat(room, user, seat_param):
"""The seat a SIG_SELECT action (reserve / ready / release) 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 hold + ready a distinct sig
per owned seat (one reservation per seat)."""
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 `
`.
token_display = dict(Token.TOKEN_TYPE_CHOICES)
positions = []
for slot in room.gate_slots.select_related("gamer").order_by("slot_number"):
gamer = slot.gamer
seat = seats_by_slot.get(slot.slot_number)
is_self = authed and gamer is not None and gamer.id == user.id
is_bud = (
authed and gamer is not None and not is_self
and gamer.id in bud_ids
)
is_me_also = is_self and slot.slot_number != current_slot
if gamer is None:
state_class = "tt-pos-empty"
elif is_self:
state_class = "tt-pos-me-also" if is_me_also else "tt-pos-me-current"
elif is_bud:
state_class = "tt-pos-gamer tt-pos-bud"
else:
state_class = "tt-pos-gamer"
# Ordered display names of the token(s) deposited in THIS slot — one
# today (each slot costs exactly 1 token; a CARTE covers each seat at
# cost 1, so a CARTE seat reads "Carte Blanche"). Becomes a multi-entry
# list when the rising-game-cost feature lands.
token_types = (
[token_display.get(slot.debited_token_type, slot.debited_token_type)]
if gamer is not None and slot.debited_token_type else []
)
sig = seat.significator if seat else None
positions.append({
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
"state_class": state_class,
"is_me_also": is_me_also,
"shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
# Per-slot expenditure count (GateSlot.token_cost) — 1 normally.
"tokens": slot.token_cost,
"token_types": token_types,
"expiry": slot.cost_current_until,
"sign_rank": sig.corner_rank if sig else "",
"sign_suit_icon": sig.suit_icon if sig else "",
})
return positions
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 _expire_lapsed_seats(room):
"""Auto-BYE gamers whose seat token cost lapsed past the renewal grace
(filled_at + 2*renewal_period). For each lapsed FILLED slot: blank the
GateSlot, blank the matching TableSeat (keep the row — `_gate_positions`
+ sig logic count seat rows), record a SLOT_RETURNED (+ retract the
prior SLOT_FILLED so the scroll's deposit↔withdraw redact-pair stays
symmetric), then flag the room RENEWAL_DUE ("gamer needed" stub). Lazy —
called on room + gate-view access, mirroring `_expire_reserved_slots`.
NULL filled_at slots are never expired (RESERVED holds / ORM fixtures /
auto-admit trinkets whose seat clock simply hasn't started)."""
span = room.renewal_period or timedelta(days=7)
cutoff = timezone.now() - 2 * span
lapsed = list(
room.gate_slots.filter(
status=GateSlot.FILLED,
filled_at__isnull=False,
filled_at__lt=cutoff,
)
)
if not lapsed:
return
for slot in lapsed:
gamer = slot.gamer
token_type = slot.debited_token_type
slot_number = slot.slot_number
if gamer is not None:
# Blank the seat row (vacate the position circle) — keep the row.
room.table_seats.filter(gamer=gamer).update(
gamer=None, role=None, role_revealed=False,
seat_position=None, significator=None, deck_variant=None,
)
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 gamer is not None and token_type:
_retract_prior_event(
room, gamer, (GameEvent.SLOT_FILLED,), slot_number=slot_number,
)
record(room, GameEvent.SLOT_RETURNED, actor=gamer,
slot_number=slot_number, token_type=token_type,
token_display=dict(Token.TOKEN_TYPE_CHOICES).get(
token_type, token_type))
room.gate_status = Room.RENEWAL_DUE
room.save(update_fields=["gate_status"])
def _gate_context(room, user, seat_param=None):
_expire_reserved_slots(room)
current_slot = _viewer_current_slot(room, user, seat_param)
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,
# Acting seat — exposed so the gate page's own GATE VIEW re-click
# re-carries ?seat (keeps the seat-switch context across reloads).
"current_slot": current_slot,
"gate_positions": _gate_positions(room, user, current_slot),
"starter_roles": [],
}
def _role_select_context(room, user, seat_param=None):
current_slot = _viewer_current_slot(room, user, seat_param)
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
# CARTE seat-switch (?seat=N): a multi-seat gamer previews a specific owned
# seat. The card-stack's active slot becomes that seat; it stays "eligible"
# (role-pickable now) only when the previewed seat is also the table's
# current turn (the lowest unassigned seat), else it shows the ineligible
# .fa-ban. No-op for the normal flow — a one-seat gamer never passes ?seat,
# and an unowned/garbage param is ignored.
if seat_param and user.is_authenticated and card_stack_state is not None:
try:
_seat_n = int(seat_param)
except (TypeError, ValueError):
_seat_n = None
if _seat_n is not None and room.table_seats.filter(
gamer=user, slot_number=_seat_n
).exists():
active_slot = _seat_n
_turn = unassigned.first() if unassigned.exists() else None
card_stack_state = (
"eligible"
if _turn is not None and _turn.slot_number == _seat_n
else "ineligible"
)
# The tray mirrors the seat the viewer is ACTING AS — the seat occupying
# current_slot (the ?seat-selected pos-circle, else their lowest owned) —
# NOT the role-canonical PC seat. So a multi-seat (CARTE) gamer's tray
# follows whichever circle they switched to. A single-seat gamer's lone
# slot IS current_slot, so selected_seat == their canonical seat (no-op).
selected_seat = (
room.table_seats.filter(gamer=user, slot_number=current_slot).first()
if user.is_authenticated and current_slot is not None else None
)
_my_role = selected_seat.role if selected_seat 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,
# The viewer's acting seat (?seat slot, else lowest owned). Threaded
# onto the GATE VIEW nav so the gate view's current matches the table
# — without it the gate view falls back to owned[0] (pos 1), locking
# pos 1 as a never-switchable me-current circle.
"current_slot": current_slot,
"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, user, current_slot),
"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
# Sig cell follows the acting seat too (see selected_seat above), so a CARTE
# gamer sees the sig of the circle they switched to. SKY_SELECT overrides
# this below from the confirmed Character.
ctx["my_tray_sig"] = selected_seat.significator if selected_seat else None
if room.table_status == Room.SIG_SELECT:
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
# CARTE seat-switch (?seat=N): a multi-seat owner picks a sig per seat,
# so the overlay must reflect the SELECTED seat (its role/polarity/deck),
# not the canonical PC seat. Gate on an EXPLICIT ?seat — with no param,
# `current_slot` is merely the lowest owned GATE slot, which need not be
# the canonical PC seat (roles aren't slot-ordered); keep the canonical
# seat in that case so every SIG_SELECT surface (incl. my_tray_sig) agrees.
if seat_param and user.is_authenticated and current_slot is not None:
_seat_override = room.table_seats.filter(
gamer=user, slot_number=current_slot
).first()
if _seat_override:
user_seat = _seat_override
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'
# Per-seat: data-ready reflects the ACTING seat's reservation, so a
# CARTE owner's WAIT-NVM state restores correctly per switched seat.
user_reservation = SigReservation.objects.filter(
room=room, gamer=user, seat=user_seat
).first() if (user.is_authenticated and user_seat) else None
ctx["user_seat"] = user_seat
ctx["user_polarity"] = user_polarity
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
# Carry the active seat on the reserve URL so sig_reserve targets THIS
# seat (per-seat sig), not the canonical one.
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
if user_seat:
ctx["sig_reserve_url"] += f"?seat={user_seat.slot_number}"
# 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)
}
# Seconds left on a live polarity countdown, so a gamer landing on
# this seat view mid-countdown restores the flashing numeral instead
# of a static WAIT NVM (countdown_start is a one-shot WS broadcast).
# None when no countdown is running → template renders 0.
from apps.epic.tasks import countdown_remaining
ctx["countdown_remaining"] = countdown_remaining(room.id, polarity_const)
else:
reservations = {}
ctx["countdown_remaining"] = None
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 ''
# Burger-fan Sky sub-btn: ACTIVE once the sky is saved (confirmed) — the
# burger then pulses thrice (--priTk) on load + the sub-btn re-opens the
# saved wheel. `saved_sky_json` primes that reopen so the felt draws the
# confirmed chart without a fresh PySwiss round-trip (mirrors My Sky).
ctx["sky_btn_active"] = sky_confirmed
ctx["saved_sky_json"] = (
json.dumps(confirmed_char.chart_data)
if (sky_confirmed and confirmed_char.chart_data)
else "null"
)
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)
_expire_lapsed_seats(room)
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)
_expire_lapsed_seats(room)
ctx = _role_select_context(room, request.user, request.GET.get("seat"))
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))
# Scroll-of-events — the table-hex aperture's 2nd snap section. Same query
# + shared `core/_partials/_scroll.html` partial as the Billscroll page
# (`billboard.views.scroll`), so the feed renders identically. Unfiltered
# (Meta-ordered by timestamp) — the `struck`/redact rendering is the
# partial's job. Only reached from Role Select onwards (the template gates
# the scroll pane on `room.table_status`).
ctx["events"] = room.events.select_related("actor").all()
ctx["viewer"] = request.user
ctx["scroll_position"] = 0
# Game-views carousel (POST view) — the room's single game-table thread.
# Lazy-created on first table-page load; its Lines render inline in the
# POST view (same `_post_line.html` partial as post.html) and the composer
# appends via `epic:room_post`. `text_btn_active` lights the burger fan's
# Text sub-btn (the swipe-machine entry to the Post view) on the table only
# — it is unset on the gatekeeper / other _burger.html surfaces, which keep
# the sub-btn in its inactive flash-stub state.
room_post = room.get_thread_post()
ctx["room_post"] = room_post
ctx["room_post_lines"] = room_post.lines.select_related("author").all()
ctx["text_btn_active"] = True
# The POST chat's participants are the gamers OCCUPYING SEATS (a TableSeat
# with their gamer) — NOT mere gate-slot/token depositors, who can retract
# their token + leave without ever committing to the game. `seated_others`
# excludes the viewer (who is the "& me" line); deduped, slot-ordered.
seen_seated = set()
seated_others = []
for seat in (room.table_seats.filter(gamer__isnull=False)
.exclude(gamer=request.user)
.select_related("gamer").order_by("slot_number")):
if seat.gamer_id not in seen_seated:
seen_seated.add(seat.gamer_id)
seated_others.append(seat.gamer)
ctx["seated_others"] = seated_others
return render(request, "apps/gameboard/room.html", ctx)
def scroll_status(request, room_id):
"""Render JUST the room scroll-of-events feed (`core/_partials/_scroll.html`)
— fetched by room-scroll.js on a `scroll_update` WS nudge so the SCROLL
applet refreshes live without a page reload. Same query + context keys as
the `events`/`viewer`/`scroll_position` block in `room_view`, so the swapped
feed renders identically to the initial paint (the redact filter + buffer
dots are re-applied client-side after the swap)."""
room = Room.objects.get(id=room_id)
return render(request, "core/_partials/_scroll.html", {
"events": room.events.select_related("actor").all(),
"viewer": request.user,
"scroll_position": 0,
})
@login_required
def room_post(request, room_id):
"""Append a Line to the room's game-table thread (the POST view of the
game-views carousel) and return the rendered line partial as JSON, so
room-views.js can splice it into `#id_post_table` without a page reload
(the carousel must stay put on the POST view). Only seated gamers may
speak at the table; the validation mirrors post.html's composer
(non-empty + no duplicate line text per thread). GET is a no-op redirect
back to the room."""
room = Room.objects.get(id=room_id)
if request.method != "POST":
return redirect("epic:room", room_id=room_id)
# Only gamers OCCUPYING A SEAT (a TableSeat with their gamer) may speak in
# the chat — NOT gate-slot/token depositors who haven't committed to a seat
# (they can retract + leave, so they must not have R/W access to the chat).
if not room.table_seats.filter(gamer=request.user).exists():
return HttpResponseForbidden()
post = room.get_thread_post()
form = ExistingPostLineForm(for_post=post, data=request.POST)
if not form.is_valid():
return JsonResponse({"ok": False, "errors": form.errors}, status=400)
line = form.save(author=request.user)
line_html = render_to_string(
"apps/billboard/_partials/_post_line.html",
{"line": line},
request=request,
)
return JsonResponse({"ok": True, "line_html": line_html})
@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)
_expire_lapsed_seats(room)
user_slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).first()
# Merge the gatekeeper gate-context so the position circles
# (_table_positions.html) render here too — it supplies gate_positions
# (now rich w. the .tt-pos-* tooltip payload) + every carte_* key the
# partial's slot conditionals read. The renewal modal's own keys override
# below.
ctx = _gate_context(room, request.user, request.GET.get("seat"))
# CONT GAME + the gear NVM both return to the table hex — but they must land
# the gamer on his ACTING seat (the ?seat-selected pos-circle), not owned[0].
# Without the ?seat a CARTE multi-seat gamer acting at pos 4 got shuttled
# back to pos 1. Mirrors the GATE VIEW nav buttons (bedc489).
table_url = reverse("epic:room", args=[room.id])
if ctx.get("current_slot"):
table_url += f"?seat={ctx['current_slot']}"
ctx.update({
"room": room,
"table_url": table_url,
"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",
# The renewal gate-view shows the circles for their rich hover tooltips
# ONLY — it is not a gather surface. Zero the CARTE drop/release form
# triggers so _table_positions renders no OK/NVM/PICK ROLES buttons here
# (renewal happens via the modal's own token rails).
"carte_next_slot_number": None,
"carte_nvm_slot_number": None,
"is_last_slot": False,
})
return render(request, "apps/gameboard/room_gate.html", ctx)
@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)
# CARTE per-seat sig: honor a ?seat=N override (carried on the reserve URL)
# so the hold targets the SELECTED owned seat, not the canonical PC one.
user_seat = _acting_sig_seat(room, request.user, request.GET.get("seat"))
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, seat=user_seat
).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, seat=user_seat
).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 SEAT already holds a *different* card — must NVM this seat
# first. Per-seat (not per-gamer): a CARTE multi-seat owner holds one
# reservation per owned seat, so the guard is scoped to user_seat.
existing = SigReservation.objects.filter(
room=room, gamer=request.user, seat=user_seat
).first()
if existing and existing.card != card:
return HttpResponse(status=409)
# Idempotent: this seat already holds the same card
if existing:
return HttpResponse(status=200)
# Block re-using a card already held for one of this gamer's OTHER seats
# in the same polarity (each seat needs a distinct sig). Normally the grid
# locks taken cards; this guards the API + avoids the (room, card, polarity)
# IntegrityError on a same-card cross-seat reserve.
if SigReservation.objects.filter(
room=room, card=card, polarity=polarity, gamer=request.user
).exclude(seat=user_seat).exists():
return HttpResponse(status=409)
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)
# No immediate commit. A CARTE multi-seat owner reserves one sig PER SEAT,
# readies each, and each polarity room runs the real 12s countdown (3 ready
# → confirm), exactly like the multi-gamer flow. `sig_confirm` / the
# countdown timer commit `seat.significator` and advance to SKY_SELECT. The
# old solo immediate-commit shortcut was demolished 2026-06-05 — it predated
# the countdown mechanism and blocked picking more than one sig per gamer.
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)
# Per-seat ready: a CARTE multi-seat owner readies each owned seat's sig
# independently (the ?seat=N rides the ready URL, like the reserve URL).
user_seat = _acting_sig_seat(room, request.user, request.GET.get("seat"))
if user_seat is None:
return HttpResponse(status=403)
action = request.POST.get("action", "ready")
reservation = SigReservation.objects.filter(
room=room, gamer=request.user, seat=user_seat
).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)