From efbf98ecf22f520d82008d4a8a75990045e25b48 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 1 Jun 2026 12:41:51 -0400 Subject: [PATCH] =?UTF-8?q?per-seat=20sig=20for=20CARTE:=20=3Fseat=20targe?= =?UTF-8?q?ts=20the=20seat=20+=20a=20solo=20polarity=20group=20commits=20t?= =?UTF-8?q?he=20sig=20on=20reserve=20=E2=80=94=20multi-gamer=20reserve?= =?UTF-8?q?=E2=86=92ready=E2=86=92countdown=E2=86=92confirm=20untouched=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/apps/epic/tests/integrated/test_views.py | 59 +++++++++++++++++++ src/apps/epic/views.py | 48 +++++++++++++++ .../test_game_room_position_tooltips.py | 14 +++-- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index a716519..6f5a3c8 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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.""" diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index efc8d06..5ec90e6 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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) diff --git a/src/functional_tests/test_game_room_position_tooltips.py b/src/functional_tests/test_game_room_position_tooltips.py index 085b14f..a57bbba 100644 --- a/src/functional_tests/test_game_room_position_tooltips.py +++ b/src/functional_tests/test_game_room_position_tooltips.py @@ -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")