Sky/Sea Select: ?seat-aware so a CARTE owner drives all 6 seats — TDD

A Carte Blanche gamer owns all 6 seats but could only cast sky / draw sea for ONE:
the sky/sea state IS per-seat (Character.seat), but the code resolved to the fixed
canonical (PC-first) seat, ignoring the ?seat switched to via the GATE VIEW pos-
circles — so the tray switched but the sky wheel / sea spread stuck on the
canonical seat + saves wrote back to it. Mirrors Sig Select's existing ?seat path.

- generalize `_acting_sig_seat` → `_acting_seat` (logic is sig-agnostic; 3 callers)
- `_role_select_context` SKY_SELECT branch keys off `selected_seat` (the ?seat-aware
  seat, already computed) instead of `_canonical_seat`: user_polarity,
  confirmed_char, user_seat_role, my_tray_sig, saved_by_position, saved_sea_spread,
  sea_default_spread, hand_complete, sea_back_image_url
- sky_save / sky_delete / sea_save / sea_delete / sea_deck resolve the acting seat
  via `_acting_seat(…, request.GET.get("seat"))`; sea_partial threads seat_param
- the sky + sea felts carry `?seat={{ current_slot }}` on their save/delete/deck/
  sea_partial action URLs so the POSTs target the switched-to seat
- single-seat flow unchanged (no ?seat → canonical fallback)
- ITs: CARTE owner — ?seat switches the displayed spread; sea_save/sky_save target
  the switched seat leaving the canonical seat's Character intact; felt URLs carry
  ?seat. 949 epic+gameboard ITs green.

; FLIP tint tweak (parallel edit): drop the polarity border, bump the flipped-back
overlay --quiUser/--terUser to 0.6 alpha

[[project-sig-select-seat-switch-open-problems]] [[project-deck-segment-model]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-07 22:44:06 -04:00
parent de59cb7e69
commit d09dca56c0
5 changed files with 138 additions and 33 deletions

View File

@@ -4191,3 +4191,103 @@ class PickSeaUnifiedFeltTest(TestCase):
self.assertIn("sea-deck-stack--levity", content)
self.assertNotIn("sea-deck-stack--single", content)
self.assertNotIn("sea-stacks--single", content)
class CarteSeatSwitchSkySeaTest(TestCase):
"""A CARTE owner (one gamer owns all 6 seats) must drive EACH seat through
CAST SKY + DRAW SEA independently. The sky/sea state is per-SEAT
(Character.seat), so ?seat=N must switch the DISPLAYED + SAVED Character —
the bug stuck everything on the canonical (PC) seat, so the tray switched but
the sky wheel / sea spread did not, and saves wrote back to the PC seat
(user-flagged 2026-06-07)."""
def setUp(self):
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.room = Room.objects.create(name="Carte Sky", owner=self.viewer)
sig = TarotCard.objects.filter(deck_variant=self.earthman, arcana="MAJOR").first()
self.seats = {}
for i, role in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"], start=1):
slot = self.room.gate_slots.get(slot_number=i)
slot.gamer = self.viewer
slot.status = GateSlot.FILLED
slot.save()
self.seats[i] = TableSeat.objects.create(
room=self.room, gamer=self.viewer, slot_number=i, role=role,
role_revealed=True, deck_variant=self.earthman, significator=sig,
)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.SKY_SELECT
self.room.save()
self.client.force_login(self.viewer)
# Slot 1 (PC = canonical) — sky confirmed WITH a sea hand.
Character.objects.create(
seat=self.seats[1], significator=sig,
chart_data={"planets": {}}, confirmed_at=timezone.now(),
celtic_cross={"spread": "waite-smith", "hand": self._hand()},
)
# Slot 3 (EC) — sky confirmed, NO sea hand yet.
Character.objects.create(
seat=self.seats[3], significator=sig,
chart_data={"planets": {}}, confirmed_at=timezone.now(),
)
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
def _hand(self):
cards = list(TarotCard.objects.filter(deck_variant=self.earthman)[:6])
positions = ["cover", "cross", "crown", "lay", "loom", "leave"]
return [{"position": p, "card_id": c.id, "reversed": False, "polarity": "gravity"}
for p, c in zip(positions, cards)]
def test_seat_param_switches_displayed_spread(self):
# ?seat=1 (PC) → its saved hand; ?seat=3 (EC) → empty. The bug showed
# seat 1's hand for BOTH (the context stuck on the canonical PC seat).
r1 = self.client.get(self.room_url + "?seat=1")
self.assertTrue(r1.context["saved_by_position"])
self.assertEqual(r1.context["saved_sea_spread"], "waite-smith")
r3 = self.client.get(self.room_url + "?seat=3")
self.assertEqual(r3.context["saved_by_position"], {})
self.assertEqual(r3.context["saved_sea_spread"], "")
def test_sea_save_targets_switched_seat_not_canonical(self):
save_url = reverse("epic:sea_save", kwargs={"room_id": self.room.id})
resp = self.client.post(
save_url + "?seat=3",
data={"spread": "escape-velocity", "hand": self._hand()},
content_type="application/json",
)
self.assertEqual(resp.status_code, 200)
# Seat 3 got the new hand; seat 1's (canonical) hand is untouched.
self.assertEqual(
Character.objects.get(seat=self.seats[3]).celtic_cross["spread"],
"escape-velocity",
)
self.assertEqual(
Character.objects.get(seat=self.seats[1]).celtic_cross["spread"],
"waite-smith",
)
def test_sky_save_confirms_switched_seat_independently(self):
# Slot 5 (AC) — no Character yet. sky_save?seat=5 confirms ITS Character.
save_url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
resp = self.client.post(
save_url + "?seat=5",
data={"chart_data": {"planets": {}}, "action": "confirm"},
content_type="application/json",
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(Character.objects.filter(
seat=self.seats[5], confirmed_at__isnull=False).exists())
# The PC seat's Character is NOT touched (still its own confirmed row).
self.assertEqual(
Character.objects.filter(seat=self.seats[1], confirmed_at__isnull=False).count(),
1,
)
def test_felt_action_urls_carry_seat_param(self):
content = self.client.get(self.room_url + "?seat=3").content.decode()
self.assertIn("/sea/save?seat=3", content)
self.assertIn("/sky/save?seat=3", content)

View File

@@ -176,11 +176,11 @@ def _canonical_user_seat(room, user):
return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()
def _acting_sig_seat(room, user, seat_param):
"""The seat a SIG_SELECT action (reserve / ready / release) targets: the
`?seat=N` override when N is one of the user's owned slots, else the
canonical seat. Lets a CARTE multi-seat owner hold + ready a distinct sig
per owned seat (one reservation per seat)."""
def _acting_seat(room, user, seat_param):
"""The seat an action targets: the `?seat=N` override when N is one of the
user's owned slots, else the canonical seat. Lets a CARTE multi-seat owner
act per-seat — a distinct sig reservation in SIG_SELECT, a distinct sky/sea
Character in SKY_SELECT — across all six owned seats."""
seat = _canonical_user_seat(room, user)
if seat_param:
try:
@@ -630,7 +630,14 @@ def _role_select_context(room, user, seat_param=None):
ctx["sig_cards"] = []
if room.table_status == Room.SKY_SELECT:
user_role = _canonical_seat.role if _canonical_seat else None
# CARTE seat-switch: the sky/sea state is per-SEAT (Character.seat), so key
# this whole block off the seat the viewer switched to (`selected_seat` =
# ?seat-aware), NOT the fixed canonical seat — without it the tray switched
# but the sky wheel / sea spread stuck on the canonical seat, so a CARTE
# owner couldn't drive his other 5 seats thru CAST SKY / DRAW SEA
# (user-flagged 2026-06-07). Falls back to canonical if unset.
_sky_seat = selected_seat or _canonical_seat
user_role = _sky_seat.role if _sky_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
user_polarity = 'levity'
@@ -639,15 +646,15 @@ def _role_select_context(room, user, seat_param=None):
ctx["user_polarity"] = user_polarity
confirmed_char = (
Character.objects.filter(
seat=_canonical_seat,
seat=_sky_seat,
confirmed_at__isnull=False,
retired_at__isnull=True,
).first()
if _canonical_seat else None
if _sky_seat else None
)
sky_confirmed = confirmed_char is not None
ctx["sky_confirmed"] = sky_confirmed
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
ctx["user_seat_role"] = _sky_seat.role if _sky_seat else ''
# Burger-fan Sky sub-btn: ACTIVE once the sky is saved (confirmed) — the
# burger then pulses thrice (--priTk) on load + the sub-btn re-opens the
# saved wheel. `saved_sky_json` primes that reopen so the felt draws the
@@ -660,7 +667,7 @@ def _role_select_context(room, user, seat_param=None):
)
if sky_confirmed:
# Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
# DRAW SEA persistence — pre-render the saved Celtic-Cross hand from
# the seat's Character.celtic_cross so a reload lands the filled
# cross + the right spread (mirrors my-sea's saved_by_position, but
@@ -684,7 +691,7 @@ def _role_select_context(room, user, seat_param=None):
# NOT request.user.equipped_deck (which `select_role` nulls out once
# the deck is contributed to the room → the back-img silently never
# rendered in the gameroom + FLIP no-op'd). User-flagged 2026-06-07.
_back_deck = _canonical_seat.deck_variant if _canonical_seat else None
_back_deck = _sky_seat.deck_variant if _sky_seat else None
ctx["sea_back_image_url"] = (
_back_deck.back_image_url
if (_back_deck and _back_deck.has_card_images) else ""
@@ -1344,7 +1351,7 @@ def sig_reserve(request, room_id):
# 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.
user_seat = _acting_sig_seat(room, request.user, request.GET.get("seat"))
user_seat = _acting_seat(room, request.user, request.GET.get("seat"))
if not user_seat or not user_seat.role:
return HttpResponse(status=403)
@@ -1440,7 +1447,7 @@ def sig_ready(request, room_id):
return HttpResponse(status=400)
# Per-seat ready: a CARTE multi-seat owner readies each owned seat's sig
# independently (the ?seat=N rides the ready URL, like the reserve URL).
user_seat = _acting_sig_seat(room, request.user, request.GET.get("seat"))
user_seat = _acting_seat(room, request.user, request.GET.get("seat"))
if user_seat is None:
return HttpResponse(status=403)
@@ -1745,7 +1752,7 @@ def sky_save(request, room_id):
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponse(status=403)
@@ -1797,7 +1804,7 @@ def sky_delete(request, room_id):
if request.method != 'POST':
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponseForbidden()
Character.objects.filter(seat=seat, retired_at__isnull=True).delete()
@@ -1829,7 +1836,7 @@ def sea_save(request, room_id):
if request.method != 'POST':
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponse(status=403)
try:
@@ -1856,7 +1863,7 @@ def sea_delete(request, room_id):
if request.method != 'POST':
return HttpResponse(status=405)
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponseForbidden()
char = _confirmed_character_for(seat)
@@ -1876,7 +1883,7 @@ def sea_deck(request, room_id):
"""
import random as _random
room = Room.objects.get(id=room_id)
seat = _canonical_user_seat(room, request.user)
seat = _acting_seat(room, request.user, request.GET.get("seat"))
if seat is None:
return HttpResponse(status=403)
@@ -1909,7 +1916,9 @@ def sea_deck(request, room_id):
def sea_partial(request, room_id):
"""Return the rendered sea overlay partial for in-page injection after sky confirm."""
room = Room.objects.get(id=room_id)
ctx = _role_select_context(room, request.user)
# ?seat threads through so the injected felt (CARTE seat-switch) reflects the
# selected seat's spread + carries the right seat onto its own action URLs.
ctx = _role_select_context(room, request.user, request.GET.get("seat"))
if not ctx.get('sky_confirmed'):
return HttpResponse(status=403)
ctx['room'] = room

View File

@@ -2347,15 +2347,11 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f
border-radius: 0.4rem;
}
}
.sea-stage--gravity .sea-stage-card.is-flipped-to-back,
.sea-stage--gravity .sea-stage-card.sig-stage-card--image.is-flipped-to-back {
border: 0.18rem solid rgba(var(--quaUser), 1);
&::after { background: rgba(var(--quiUser), 0.3); }
.sea-stage--gravity .sea-stage-card.is-flipped-to-back {
&::after { background: rgba(var(--quiUser), 0.6); }
}
.sea-stage--levity .sea-stage-card.is-flipped-to-back,
.sea-stage--levity .sea-stage-card.sig-stage-card--image.is-flipped-to-back {
border: 0.18rem solid rgba(var(--ninUser), 1);
&::after { background: rgba(var(--terUser), 0.3); }
.sea-stage--levity .sea-stage-card.is-flipped-to-back {
&::after { background: rgba(var(--terUser), 0.6); }
}
// Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage.

View File

@@ -14,9 +14,9 @@ Each draw persists onto the seat's Character.celtic_cross via epic:sea_save.
{% endcomment %}
<div class="sea-page sea-page--room" id="id_sea_page">
<div class="my-sea-picker{% if hand_complete %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"
data-sea-deck-url="{% url 'epic:sea_deck' room.id %}"
data-sea-save-url="{% url 'epic:sea_save' room.id %}"
data-sea-delete-url="{% url 'epic:sea_delete' room.id %}"
data-sea-deck-url="{% url 'epic:sea_deck' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-sea-save-url="{% url 'epic:sea_save' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-sea-delete-url="{% url 'epic:sea_delete' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-sea-user-polarity="{{ user_polarity }}">
{# ── Felt cross — the REAL deal target (.my-sea-cross). Sig pins core; the #}

View File

@@ -11,9 +11,9 @@
<div class="sky-page sky-page--room"
id="id_sky_overlay"
data-preview-url="{% url 'epic:sky_preview' room.id %}"
data-save-url="{% url 'epic:sky_save' room.id %}"
data-delete-url="{% url 'epic:sky_delete' room.id %}"
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}"
data-save-url="{% url 'epic:sky_save' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-delete-url="{% url 'epic:sky_delete' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-sea-partial-url="{% url 'epic:sea_partial' room.id %}{% if current_slot %}?seat={{ current_slot }}{% endif %}"
data-user-seat-role="{{ user_seat_role }}">
<div class="sky-modal-body">