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:
@@ -1302,6 +1302,65 @@ def _full_sig_setUp(test_case, role_order=None):
|
|||||||
return room, gamers, earthman, card_in_deck
|
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):
|
class SigSelectRenderingTest(TestCase):
|
||||||
"""Gate view at SIG_SELECT renders the Significator deck."""
|
"""Gate view at SIG_SELECT renders the Significator deck."""
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,16 @@ def _role_select_context(room, user, seat_param=None):
|
|||||||
|
|
||||||
if room.table_status == Room.SIG_SELECT:
|
if room.table_status == Room.SIG_SELECT:
|
||||||
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
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_role = user_seat.role if user_seat else None
|
||||||
user_polarity = None
|
user_polarity = None
|
||||||
if user_role in _LEVITY_ROLES:
|
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_seat"] = user_seat
|
||||||
ctx["user_polarity"] = user_polarity
|
ctx["user_polarity"] = user_polarity
|
||||||
ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
|
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"
|
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?
|
# Has this gamer's polarity already had significators assigned?
|
||||||
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
|
# (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)
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
user_seat = _canonical_user_seat(room, request.user)
|
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:
|
if not user_seat or not user_seat.role:
|
||||||
return HttpResponse(status=403)
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
@@ -1195,6 +1223,26 @@ def sig_reserve(request, room_id):
|
|||||||
seat=user_seat, role=user_seat.role, polarity=polarity,
|
seat=user_seat, role=user_seat.role, polarity=polarity,
|
||||||
)
|
)
|
||||||
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
_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)
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -245,23 +245,25 @@ class CarteSeatSwitchTest(FunctionalTest):
|
|||||||
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
|
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
|
||||||
stack.find_element(By.CSS_SELECTOR, ".fa-ban")
|
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):
|
def test_carte_saves_a_significator_per_seat(self):
|
||||||
# Sig Select: the viewer saves a sig on PC (seat 1), switches to seat 2,
|
# Sig Select: the viewer reserves a sig on PC (seat 1) — a solo-owned
|
||||||
# and saves a DIFFERENT sig there — proving per-seat (not per-gamer) sig.
|
# 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")
|
self.create_pre_authenticated_session("disco@test.io")
|
||||||
_assign_all_roles(self.room) # roles + advance to SIG_SELECT
|
_assign_all_roles(self.room) # roles + advance to SIG_SELECT
|
||||||
self.room.refresh_from_db()
|
self.room.refresh_from_db()
|
||||||
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
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.browser.get(
|
||||||
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=1")
|
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=1")
|
||||||
card1 = self.wait_for(
|
card1 = self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, "#id_sig_deck [data-card-id]"))
|
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(
|
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.
|
# Switch to seat 2 — its sig pick is independent of seat 1's.
|
||||||
self.browser.get(
|
self.browser.get(
|
||||||
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=2")
|
self.live_server_url + f"/gameboard/room/{self.room.id}/?seat=2")
|
||||||
|
|||||||
Reference in New Issue
Block a user