diff --git a/src/apps/epic/consumers.py b/src/apps/epic/consumers.py index 08f5a66..1cb33af 100644 --- a/src/apps/epic/consumers.py +++ b/src/apps/epic/consumers.py @@ -90,5 +90,8 @@ class RoomConsumer(AsyncJsonWebsocketConsumer): async def pick_sky_available(self, event): await self.send_json(event) + async def sky_confirmed(self, event): + await self.send_json(event) + async def cursor_move(self, event): await self.send_json(event) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index a3a047b..4e73432 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -27,4 +27,5 @@ urlpatterns = [ path('room//tarot/deal', views.tarot_deal, name='tarot_deal'), path('room//natus/preview', views.natus_preview, name='natus_preview'), path('room//natus/save', views.natus_save, name='natus_save'), + path('room//sea/partial', views.sea_partial, name='sea_partial'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 2e63114..e0e306e 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -126,6 +126,13 @@ def _notify_pick_sky_available(room_id): ) +def _notify_sky_confirmed(room_id, seat_role): + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'sky_confirmed', 'seat_role': seat_role}, + ) + + SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} _SIG_SEAT_ORDERING = Case( @@ -362,6 +369,7 @@ def _role_select_context(room, user): ) sky_confirmed = confirmed_char is not None ctx["sky_confirmed"] = sky_confirmed + ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else '' if sky_confirmed: ctx["my_tray_sig"] = confirmed_char.significator @@ -1093,5 +1101,19 @@ def natus_save(request, room_id): char.confirmed_at = timezone.now() char.save() + + if char.is_confirmed: + _notify_sky_confirmed(room_id, seat.role) + return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed}) + +@login_required +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) + if not ctx.get('sky_confirmed'): + return HttpResponse(status=403) + return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx) + diff --git a/src/functional_tests/test_room_sea_select.py b/src/functional_tests/test_room_sea_select.py new file mode 100644 index 0000000..3903069 --- /dev/null +++ b/src/functional_tests/test_room_sea_select.py @@ -0,0 +1,131 @@ +"""Functional tests for the PICK SEA overlay — Celtic Cross draw.""" + +from django.urls import reverse +from selenium.webdriver.common.by import By + +from apps.applets.models import Applet +from apps.epic.models import GateSlot, Room, TableSeat, TarotCard, DeckVariant + +from .base import ChannelsFunctionalTest + + +def _make_sky_confirmed_room(live_server_url, user, earthman): + """Create a SKY_SELECT room with one gamer seated and sig assigned. + + Returns (room, seat). The Character is NOT yet confirmed — call + _confirm_sky() in the browser to trigger the async transition. + """ + room = Room.objects.create( + name="Sea Test Room", table_status=Room.SKY_SELECT, owner=user + ) + slot = room.gate_slots.get(slot_number=1) + slot.gamer = user + slot.status = GateSlot.FILLED + slot.save() + room.gate_status = Room.OPEN + room.save() + + sig_card = TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR").first() + seat = TableSeat.objects.create( + room=room, gamer=user, role="PC", slot_number=1, + deck_variant=earthman, significator=sig_card, + ) + return room, seat + + +class PickSeaAsyncTransitionTest(ChannelsFunctionalTest): + """After sky confirm, PICK SEA overlay appears without a page refresh.""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + Applet.objects.get_or_create( + slug="my-games", defaults={"name": "My Games", "context": "gameboard"} + ) + from apps.lyric.models import User + gamer, _ = User.objects.get_or_create(email="founder@test.io") + earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + gamer.unlocked_decks.add(earthman) + gamer.equipped_deck = earthman + gamer.save(update_fields=["equipped_deck"]) + + self.gamer = gamer + self.room, self.seat = _make_sky_confirmed_room(self.live_server_url, gamer, earthman) + self.room_url = self.live_server_url + reverse( + "epic:room", kwargs={"room_id": self.room.id} + ) + self.natus_save_url = self.live_server_url + reverse( + "epic:natus_save", kwargs={"room_id": self.room.id} + ) + + def _confirm_sky(self): + """POST to natus_save with action=confirm from browser JS (bypasses chart form).""" + # Wait for the room WS connection to be ready before triggering confirm + self.wait_for(lambda: self.browser.execute_script( + "return !!(window._roomSocket && window._roomSocket.readyState === 1);" + )) + self.browser.execute_script(f""" + const csrf = (document.cookie.match(/csrftoken=([^;]+)/) || ['',''])[1]; + fetch('{self.natus_save_url}', {{ + method: 'POST', + credentials: 'same-origin', + headers: {{'Content-Type': 'application/json', 'X-CSRFToken': csrf}}, + body: JSON.stringify({{ + birth_dt: '1990-06-15T09:00:00Z', + birth_lat: 51.5, birth_lon: -0.1, + birth_place: 'London', house_system: 'O', + chart_data: {{}}, action: 'confirm', + }}), + }}); + """) + + def test_sea_overlay_appears_without_page_refresh(self): + """Confirming sky replaces the natus overlay with the sea overlay in-place.""" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.room_url) + + # Sky not yet confirmed — PICK SKY btn present, no sea overlay + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + self.assertEqual(self.browser.find_elements(By.ID, "id_sea_overlay"), []) + + self._confirm_sky() + + # Sea overlay appears without page refresh + sea_overlay = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_sea_overlay") + ) + self.assertTrue(sea_overlay.is_displayed()) + + def test_natus_overlay_not_visible_after_sky_confirm(self): + """Natus overlay is removed from the DOM after sky confirm.""" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + + self._confirm_sky() + + # Sea overlay must appear first (confirms transition happened) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay")) + + natus = self.browser.find_elements(By.ID, "id_natus_overlay") + self.assertTrue(not natus or not natus[0].is_displayed()) + + def test_sea_open_class_on_html_after_confirm(self): + """html.sea-open is set after sky confirm, giving the sea overlay its backdrop.""" + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_pick_sky_btn")) + + self._confirm_sky() + + self.wait_for(lambda: self.browser.find_element(By.ID, "id_sea_overlay")) + has_sea_open = self.browser.execute_script( + "return document.documentElement.classList.contains('sea-open');" + ) + self.assertTrue(has_sea_open) diff --git a/src/templates/apps/gameboard/_partials/_natus_overlay.html b/src/templates/apps/gameboard/_partials/_natus_overlay.html index 3c78be2..cf376f9 100644 --- a/src/templates/apps/gameboard/_partials/_natus_overlay.html +++ b/src/templates/apps/gameboard/_partials/_natus_overlay.html @@ -8,7 +8,9 @@
+ data-save-url="{% url 'epic:natus_save' room.id %}" + data-sea-partial-url="{% url 'epic:sea_partial' room.id %}" + data-user-seat-role="{{ user_seat_role }}">
@@ -369,9 +371,11 @@ if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(() => { - setStatus('Sky saved!'); - setTimeout(closeNatus, 1200); + .then(data => { + if (!data.confirmed) { + setStatus('Sky saved!'); + } + // Confirmed state is driven by the room:sky_confirmed WS event }) .catch(err => { setStatus(`Save failed: ${err.message}`, 'error'); @@ -379,6 +383,28 @@ }); }); + // ── Sky confirmed → inject sea partial ─────────────────────────────────── + + const SEA_PARTIAL_URL = overlay.dataset.seaPartialUrl; + + function _onSkyConfirmed() { + fetch(SEA_PARTIAL_URL, { credentials: 'same-origin' }) + .then(r => r.text()) + .then(html => { + // Remove natus overlay + backdrop; inject sea partial before body close + var backdrop = document.querySelector('.natus-backdrop'); + if (backdrop) backdrop.remove(); + overlay.remove(); + document.documentElement.classList.remove('natus-open'); + document.body.insertAdjacentHTML('beforeend', html); + document.documentElement.classList.add('sea-open'); + }) + .catch(() => { + // Fallback: just close natus and let page refresh handle the transition + closeNatus(); + }); + } + // ── CSRF ────────────────────────────────────────────────────────────────── function _getCsrf() { @@ -391,6 +417,15 @@ // openNatus() so the animation plays when the modal opens, not silently // in the background on page load. + // WS: server broadcasts sky_confirmed when any gamer confirms their sky. + // Only act when the event's seat_role matches this browser's seat. + const MY_SEAT_ROLE = overlay.dataset.userSeatRole; + + window.addEventListener('room:sky_confirmed', function (e) { + if (MY_SEAT_ROLE && e.detail.seat_role && e.detail.seat_role !== MY_SEAT_ROLE) return; + _onSkyConfirmed(); + }); + _restoreForm(); })();