Tray follows the ?seat-selected seat, not the canonical PC seat

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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-04 15:00:25 -04:00
parent 8c5d77d696
commit 4c484cf25a
2 changed files with 89 additions and 2 deletions

View File

@@ -810,6 +810,81 @@ class PositionTooltipCarteRenderTest(TestCase):
self.assertNotIn("drop-token-btn", content) 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): class PickRolesViewTest(TestCase):
def setUp(self): def setUp(self):
self.founder = User.objects.create(email="founder@test.io") self.founder = User.objects.create(email="founder@test.io")

View File

@@ -458,7 +458,16 @@ def _role_select_context(room, user, seat_param=None):
if _turn is not None and _turn.slot_number == _seat_n if _turn is not None and _turn.slot_number == _seat_n
else "ineligible" 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). # `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 # 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 # — 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) # Tray cell 2: sig card (set once polarity group confirms)
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None _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: 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