per-seat sig for CARTE: ?seat targets the seat + a solo polarity group commits the sig on reserve — multi-gamer reserve→ready→countdown→confirm untouched — TDD

Workstream C of the position-circle tooltips sprint (sprint complete).

Per-seat (not per-gamer) significators for a CARTE solo owner, WITHOUT flipping the SigReservation (room,gamer) unique constraint (which the recon confirmed would break 25+ multi-gamer sig tests + the channels flow).

- sig_reserve resolves the active seat from ?seat=N (carried on the reserve URL) when the viewer owns it, else _canonical_user_seat — so the hold/commit targets the SELECTED seat.
- When the polarity group is SOLO-owned (the viewer owns every PC/NC/SC or BC/EC/AC seat — a CARTE table), reserving commits seat.significator immediately: the 3-ready countdown can never complete solo. The committed sig persists through a NVM release (which only deletes the provisional row), so the viewer reserves each seat in turn. Advances to SKY_SELECT once every seat has a sig (mirrors sig_confirm's tail). Strictly gated to the solo case — a multi-gamer polarity group still rides the existing countdown contract untouched.
- _role_select_context SIG_SELECT branch: overrides user_seat by ?seat (owned) so the overlay reflects the selected seat's role/polarity/deck, and carries ?seat on sig_reserve_url.
- FT refined to the real reserve mechanic (in-card OK btn → .sig-reserved) — the RED spec's .sig-card.reserved / single-body-click was a placeholder; behavior verified is unchanged (per-seat sig persistence).

Tests: CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat FT green (all 4 CarteSeatSwitch + 7 PositionTooltip now green, no skips); 2 solo-CARTE sig ITs (SigReserveSoloCarteTest); full suite 1602 green; channels consumer tests 5 green (WS broadcast intact); multi-gamer SigReserveViewTest/sig_ready/sig_confirm untouched.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-01 12:41:51 -04:00
parent d190b37149
commit efbf98ecf2
3 changed files with 115 additions and 6 deletions

View File

@@ -1302,6 +1302,65 @@ def _full_sig_setUp(test_case, role_order=None):
return room, gamers, earthman, card_in_deck
class SigReserveSoloCarteTest(TestCase):
"""CARTE solo: one gamer owns every seat, so each polarity group is
solo-owned — reserving commits the sig to the active (?seat) seat
immediately (no 3-gamer countdown can ever complete), and the sig is
per-seat, not per-gamer. The fast IT counterpart to
CarteSeatSwitchTest.test_carte_saves_a_significator_per_seat."""
def setUp(self):
from apps.epic.models import DeckVariant
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
self.viewer = User.objects.create(email="disco@test.io", username="disco")
self.viewer.equipped_deck = self.earthman
self.viewer.save(update_fields=["equipped_deck"])
self.room = Room.objects.create(name="Carte Sig", owner=self.viewer)
for i, role in enumerate(SIG_SEAT_ORDER, start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = self.viewer
slot.status = GateSlot.FILLED
slot.save()
TableSeat.objects.create(
room=self.room, gamer=self.viewer, slot_number=i, role=role,
role_revealed=True, deck_variant=self.earthman,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.SIG_SELECT
self.room.save()
self.client.force_login(self.viewer)
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
# Two distinct levity court cards (PC + NC are both levity).
self.card_a = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11)
self.card_b = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12)
def test_solo_reserve_commits_significator_to_active_seat(self):
self.client.post(self.url + "?seat=1",
data={"card_id": self.card_a.id, "action": "reserve"})
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertEqual(seat1.significator_id, self.card_a.id)
def test_solo_sig_is_per_seat_not_per_gamer(self):
# Commit seat 1, NVM (frees the per-gamer row; the committed sig
# persists through release), then commit a different card on seat 2.
self.client.post(self.url + "?seat=1",
data={"card_id": self.card_a.id, "action": "reserve"})
self.client.post(self.url,
data={"card_id": self.card_a.id, "action": "release"})
self.client.post(self.url + "?seat=2",
data={"card_id": self.card_b.id, "action": "reserve"})
seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
seat2 = TableSeat.objects.get(room=self.room, slot_number=2)
self.assertEqual(seat1.significator_id, self.card_a.id)
self.assertEqual(seat2.significator_id, self.card_b.id)
self.assertNotEqual(seat1.significator_id, seat2.significator_id)
class SigSelectRenderingTest(TestCase):
"""Gate view at SIG_SELECT renders the Significator deck."""

View File

@@ -512,6 +512,16 @@ def _role_select_context(room, user, seat_param=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. `current_slot` is the ?seat-resolved owned
# slot (falls back to canonical for a one-seat gamer, who never switches).
if 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:
@@ -525,7 +535,11 @@ def _role_select_context(room, user, seat_param=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.)
@@ -1139,6 +1153,20 @@ def sig_reserve(request, room_id):
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
if not user_seat or not user_seat.role:
return HttpResponse(status=403)
@@ -1195,6 +1223,26 @@ def sig_reserve(request, room_id):
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)
return HttpResponse(status=200)

View File

@@ -245,23 +245,25 @@ class CarteSeatSwitchTest(FunctionalTest):
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
stack.find_element(By.CSS_SELECTOR, ".fa-ban")
@skip(_RED + " [workstream C — solo-group sig persistence]")
def test_carte_saves_a_significator_per_seat(self):
# Sig Select: the viewer saves a sig on PC (seat 1), switches to seat 2,
# and saves a DIFFERENT sig there — proving per-seat (not per-gamer) sig.
# Sig Select: the viewer reserves a sig on PC (seat 1) — a solo-owned
# polarity group commits it to THAT seat immediately (no 3-gamer
# countdown). Switching to seat 2 shows its own deck, proving per-seat
# (not per-gamer) sig.
self.create_pre_authenticated_session("disco@test.io")
_assign_all_roles(self.room) # roles + advance to SIG_SELECT
self.room.refresh_from_db()
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
# Seat 1 (PC) — pick its sig
# Seat 1 (PC) — reserve its sig via the in-card OK button.
self.browser.get(
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=1")
card1 = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_sig_deck [data-card-id]"))
card1.click()
ok = card1.find_element(By.CSS_SELECTOR, ".sig-ok-btn")
self.browser.execute_script("arguments[0].click()", ok)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card.reserved"))
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card.sig-reserved"))
# Switch to seat 2 — its sig pick is independent of seat 1's.
self.browser.get(
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=2")