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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user