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:
Disco DeDisco
2026-06-05 02:29:12 -04:00
parent bedc489d7b
commit c2b244d796
7 changed files with 188 additions and 89 deletions

View File

@@ -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: