sig-select sprint: SigReservation model + sig_reserve view (OK/NVM hold); full sig-select.js rewrite with stage preview, WS hover cursors, reservation lock (must NVM before OK-ing another card — enforced server-side 409 + JS guard); sizeSigModal() + sizeSigCard() in room.js (JS-based card sizing avoids libsass cqw/cqh limitation); stat block hidden until OK pressed; mobile touch: dismiss stage on outside-grid tap when unfocused; 17 IT + Jasmine specs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-05 22:01:23 -04:00
parent a15d91dfe6
commit c7370bda03
18 changed files with 1616 additions and 172 deletions

View File

@@ -1,3 +1,4 @@
import json
from datetime import timedelta
from asgiref.sync import async_to_sync
@@ -10,8 +11,9 @@ from django.utils import timezone
from apps.drama.models import GameEvent, record
from apps.epic.models import (
GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck,
active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order,
GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, TarotDeck,
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
select_token, sig_deck_cards,
)
from apps.lyric.models import Token
@@ -74,6 +76,20 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
)
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
def _notify_sig_reserved(room_id, card_id, role, reserved):
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
async_to_sync(get_channel_layer().group_send)(
f'cursors_{room_id}_{polarity}',
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
'role': role, 'reserved': reserved},
)
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
@@ -216,16 +232,35 @@ def _role_select_context(room, user):
}
if room.table_status == Room.SIG_SELECT:
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
user_role = user_seat.role if user_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
user_polarity = 'levity'
elif user_role in _GRAVITY_ROLES:
user_polarity = 'gravity'
ctx["user_seat"] = user_seat
ctx["partner_seat"] = partner_seat
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
raw_sig_cards = sig_deck_cards(room)
half = len(raw_sig_cards) // 2
ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]]
ctx["sig_seats"] = sig_seat_order(room)
ctx["sig_active_seat"] = active_sig_seat(room)
ctx["user_polarity"] = user_polarity
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
# Pre-load existing reservations for this polarity so JS can restore
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
if user_polarity:
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
reservations = {
str(res.card_id): res.role
for res in room.sig_reservations.filter(polarity=polarity_const)
}
else:
reservations = {}
ctx["sig_reservations_json"] = json.dumps(reservations)
if user_polarity == 'levity':
ctx["sig_cards"] = levity_sig_cards(room)
elif user_polarity == 'gravity':
ctx["sig_cards"] = gravity_sig_cards(room)
else:
ctx["sig_cards"] = []
return ctx
@@ -526,6 +561,60 @@ def gate_status(request, room_id):
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=<uuid>, action=reserve|release
"""
if request.method != "POST":
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
if room.table_status != Room.SIG_SELECT:
return HttpResponse(status=400)
user_seat = room.table_seats.filter(gamer=request.user).first()
if not user_seat or not user_seat.role:
return HttpResponse(status=403)
action = request.POST.get("action", "reserve")
if action == "release":
SigReservation.objects.filter(room=room, gamer=request.user).delete()
_notify_sig_reserved(room_id, None, user_seat.role, reserved=False)
return HttpResponse(status=200)
# Reserve action
card_id = request.POST.get("card_id")
try:
card = TarotCard.objects.get(pk=card_id)
except TarotCard.DoesNotExist:
return HttpResponse(status=400)
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
# Block if another gamer in the same polarity already holds this card
if SigReservation.objects.filter(
room=room, card=card, polarity=polarity
).exclude(gamer=request.user).exists():
return HttpResponse(status=409)
# Block if this gamer already holds a *different* card — must NVM first
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
if existing and existing.card != card:
return HttpResponse(status=409)
# Idempotent: already holding the same card
if existing:
return HttpResponse(status=200)
SigReservation.objects.create(
room=room, gamer=request.user, card=card,
role=user_seat.role, polarity=polarity,
)
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
return HttpResponse(status=200)
@login_required
def select_sig(request, room_id):
if request.method != "POST":