From 4c484cf25adaef04bd04e9a9caa803aeb007bc25 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 4 Jun 2026 15:00:25 -0400 Subject: [PATCH] Tray follows the ?seat-selected seat, not the canonical PC seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-seat (CARTE) gamer clicking a pos-circle switched the sig overlay (via the ?seat=N override) but the tray stayed pinned to the role-canonical PC seat — my_tray_role read assigned_seats[0] (role-sorted, always PC) and my_tray_sig read _canonical_user_seat. So every circle put the PC icon in the tray regardless of which one was clicked. Re-point both tray keys to the seat occupying current_slot (the acting seat). Single-seat gamers are unaffected — their lone slot IS current_slot, so selected_seat == their canonical seat. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/epic/tests/integrated/test_views.py | 75 ++++++++++++++++++++ src/apps/epic/views.py | 16 ++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 4e846a7..34c0538 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -810,6 +810,81 @@ class PositionTooltipCarteRenderTest(TestCase): self.assertNotIn("drop-token-btn", content) +class CarteTrayFollowsSelectedSeatTest(TestCase): + """A multi-seat (CARTE) gamer's tray must mirror the seat at the + ?seat-selected pos-circle (current_slot), not the canonical PC seat. + + Regression: my_tray_role / my_tray_sig were pinned to the role-sorted + assigned_seats[0] (always PC) and _canonical_user_seat, so clicking ANY + pos-circle put the PC icon in the tray. The seat-switch (?seat=N) only + re-pointed the sig OVERLAY; the tray ignored it entirely.""" + + # Reverse canonical pick order: select_role fills the lowest open slot + # each turn, so picking BC,AC,SC,EC,NC,PC seats them at slots 1..6. + SLOT_ROLES = {1: "BC", 2: "AC", 3: "SC", 4: "EC", 5: "NC", 6: "PC"} + + def setUp(self): + self.viewer = User.objects.create(email="disco@test.io", username="disco") + self.deck, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman", "card_count": 106, "is_default": True}, + ) + self.room = Room.objects.create(name="Carte Room", owner=self.viewer) + self.room.gate_slots.update( + gamer=self.viewer, status=GateSlot.FILLED, + filled_at=timezone.now(), debited_token_type=Token.CARTE, + ) + Token.objects.create( + user=self.viewer, token_type=Token.CARTE, + current_room=self.room, slots_claimed=6, + ) + self.room.gate_status = Room.OPEN + self.room.table_status = Room.ROLE_SELECT + self.room.save() + for n, role in self.SLOT_ROLES.items(): + TableSeat.objects.create( + room=self.room, gamer=self.viewer, slot_number=n, + role=role, deck_variant=self.deck, + ) + self.client.force_login(self.viewer) + self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def test_seat_param_points_tray_role_at_that_seat(self): + # ?seat=4 → pos-circle 4 holds EC, so the tray role card is EC, not PC. + response = self.client.get(self.room_url + "?seat=4") + self.assertEqual(response.context["my_tray_role"], "EC") + + def test_each_owned_circle_yields_its_own_seat_role(self): + for n, role in self.SLOT_ROLES.items(): + response = self.client.get(self.room_url + f"?seat={n}") + self.assertEqual( + response.context["my_tray_role"], role, + f"pos-circle {n} should put role {role} in the tray", + ) + + def test_default_no_seat_tray_follows_lowest_owned_circle(self): + # No ?seat → current_slot is the lowest owned pos-circle (1 = BC), + # NOT the role-canonical PC seat (slot 6). + response = self.client.get(self.room_url) + self.assertEqual(response.context["my_tray_role"], "BC") + + def test_seat_param_points_tray_sig_at_that_seat(self): + sig = TarotCard.objects.create( + deck_variant=self.deck, slug="ec-sig", + arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands", + ) + ec_seat = TableSeat.objects.get(room=self.room, slot_number=4) + ec_seat.significator = sig + ec_seat.save(update_fields=["significator"]) + # Switch to EC's circle → its sig rides the tray. + response = self.client.get(self.room_url + "?seat=4") + self.assertEqual(response.context["my_tray_sig"], sig) + # The PC circle (slot 6) has no sig → switching there shows none + # (proving the tray no longer falls back to a single canonical seat). + response = self.client.get(self.room_url + "?seat=6") + self.assertIsNone(response.context["my_tray_sig"]) + + class PickRolesViewTest(TestCase): def setUp(self): self.founder = User.objects.create(email="founder@test.io") diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index bc34339..c691db9 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -458,7 +458,16 @@ def _role_select_context(room, user, seat_param=None): if _turn is not None and _turn.slot_number == _seat_n else "ineligible" ) - _my_role = assigned_seats[0].role if assigned_seats else None + # The tray mirrors the seat the viewer is ACTING AS — the seat occupying + # current_slot (the ?seat-selected pos-circle, else their lowest owned) — + # NOT the role-canonical PC seat. So a multi-seat (CARTE) gamer's tray + # follows whichever circle they switched to. A single-seat gamer's lone + # slot IS current_slot, so selected_seat == their canonical seat (no-op). + selected_seat = ( + room.table_seats.filter(gamer=user, slot_number=current_slot).first() + if user.is_authenticated and current_slot is not None else None + ) + _my_role = selected_seat.role if selected_seat else None # `equipped_deck_id` gates the JS role-select guard (role-select.js L165). # Falls back to ANY of the user's seats in this room w. deck_variant set # — covers the CARTE multi-seat return path where the first role-pick @@ -517,7 +526,10 @@ def _role_select_context(room, user, seat_param=None): # Tray cell 2: sig card (set once polarity group confirms) _canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None - ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None + # Sig cell follows the acting seat too (see selected_seat above), so a CARTE + # gamer sees the sig of the circle they switched to. SKY_SELECT overrides + # this below from the confirmed Character. + ctx["my_tray_sig"] = selected_seat.significator if selected_seat else None if room.table_status == Room.SIG_SELECT: user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None