Solo CARTE sig-select runs the real per-polarity countdown — TDD
A CARTE gamer owning multiple seats was capped at one SigReservation per room by the (room, gamer) unique constraint, and sig_reserve had a solo immediate-commit shortcut that wrote seat.significator on OK and never ran a countdown. Together they 409'd the second sig: after OK-ing one seat, switching to another and clicking OK was blocked — a gamer could only ever pick one sig. The countdown/confirm machinery (sig_confirm + the threading-timer task) was already per-seat-row based — it iterates every ready reservation in a polarity and seats each. So the fix is to let one gamer hold one reservation PER SEAT: - Constraint (room, gamer) -> (room, gamer, seat) (migration 0018). The (room, card, polarity) constraint still enforces a distinct sig per seat. - Demolish the solo immediate-commit shortcut in sig_reserve — it predated the countdown mechanism. Reserve now always just creates a provisional row. - Scope the reserve guard, release, and idempotency lookups to the acting seat via a new _acting_sig_seat(room, user, ?seat) helper; add a guard against re-using a card already held for another of the gamer's seats. - sig_ready resolves the acting seat the same way (?seat) and looks up the per-seat reservation; data-ready-url + user_ready now carry/reflect the seat. Now a solo CARTE tester reserves + readies all 3 sigs per polarity and each room fires its own 12s countdown, then both confirm -> SKY_SELECT. Tests: new SigReserveCarteMultiSeatTest (no-NVM per-seat reserve; same-seat 409 retained; 3-levity-ready fires one countdown); rewrote the model constraint test + the CARTE FT to the new behaviour. Multi-gamer path unchanged (one row per gamer) — epic 595 + gameboard 319 + epic channels 5 + jasmine all green; full 1663-test suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,25 @@ def _canonical_user_seat(room, user):
|
||||
"""
|
||||
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",
|
||||
@@ -559,9 +578,11 @@ def _role_select_context(room, user, seat_param=None):
|
||||
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
|
||||
).first() if user.is_authenticated else None
|
||||
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)
|
||||
@@ -1267,28 +1288,18 @@ def sig_reserve(request, room_id):
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
user_seat = _canonical_user_seat(room, request.user)
|
||||
# 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.
|
||||
seat_param = request.GET.get("seat")
|
||||
if seat_param:
|
||||
try:
|
||||
_seat_n = int(seat_param)
|
||||
except (TypeError, ValueError):
|
||||
_seat_n = None
|
||||
if _seat_n is not None:
|
||||
_override = room.table_seats.filter(
|
||||
gamer=request.user, slot_number=_seat_n
|
||||
).first()
|
||||
if _override:
|
||||
user_seat = _override
|
||||
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).first()
|
||||
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
|
||||
@@ -1305,7 +1316,9 @@ def sig_reserve(request, room_id):
|
||||
).count() == 3
|
||||
if all_ready:
|
||||
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
|
||||
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||
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)
|
||||
|
||||
@@ -1324,40 +1337,40 @@ def sig_reserve(request, room_id):
|
||||
).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()
|
||||
# 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: already holding the same card
|
||||
# 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)
|
||||
|
||||
# Solo polarity group (CARTE — the viewer owns EVERY seat in this polarity,
|
||||
# so there is no co-gamer to ready/countdown-sync against). Commit the sig
|
||||
# to the active seat right away; the 3-ready countdown can never complete
|
||||
# solo. The provisional row stays (a NVM frees it for the next seat, and
|
||||
# seat.significator persists through release). Strictly gated to the solo
|
||||
# case so the multi-gamer reserve→ready→countdown→confirm contract — and
|
||||
# its channels tests — are untouched. [[project-position-circle-tooltips]]
|
||||
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||
solo_group = not room.table_seats.filter(
|
||||
role__in=polarity_roles
|
||||
).exclude(gamer=request.user).exists()
|
||||
if solo_group:
|
||||
user_seat.significator = card
|
||||
user_seat.save(update_fields=["significator"])
|
||||
# Solo player has committed every seat's sig → advance to SKY_SELECT
|
||||
# (mirrors sig_confirm's tail; no countdown ever fires solo).
|
||||
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)
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -1371,12 +1384,16 @@ def sig_ready(request, room_id):
|
||||
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)
|
||||
# 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).first()
|
||||
reservation = SigReservation.objects.filter(
|
||||
room=room, gamer=request.user, seat=user_seat
|
||||
).first()
|
||||
|
||||
if action == "ready":
|
||||
if reservation is None:
|
||||
|
||||
Reference in New Issue
Block a user