diff --git a/src/apps/epic/migrations/0031_sig_ready_sky_select.py b/src/apps/epic/migrations/0031_sig_ready_sky_select.py new file mode 100644 index 0000000..5a4b8c0 --- /dev/null +++ b/src/apps/epic/migrations/0031_sig_ready_sky_select.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0 on 2026-04-09 04:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0030_sigreservation_seat_fk'), + ] + + operations = [ + migrations.AddField( + model_name='room', + name='sig_select_started_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='sigreservation', + name='countdown_remaining', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='sigreservation', + name='ready', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='room', + name='table_status', + field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index ed5f999..8b3a32b 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -33,10 +33,12 @@ class Room(models.Model): ROLE_SELECT = "ROLE_SELECT" SIG_SELECT = "SIG_SELECT" + SKY_SELECT = "SKY_SELECT" IN_GAME = "IN_GAME" TABLE_STATUS_CHOICES = [ (ROLE_SELECT, "Role Select"), (SIG_SELECT, "Significator Select"), + (SKY_SELECT, "Sky Select"), (IN_GAME, "In Game"), ] @@ -50,6 +52,7 @@ class Room(models.Model): table_status = models.CharField( max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True ) + sig_select_started_at = models.DateTimeField(null=True, blank=True) renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7)) created_at = models.DateTimeField(auto_now_add=True) board_state = models.JSONField(default=dict) @@ -369,6 +372,8 @@ class SigReservation(models.Model): role = models.CharField(max_length=2) polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES) reserved_at = models.DateTimeField(auto_now_add=True) + ready = models.BooleanField(default=False) + countdown_remaining = models.IntegerField(null=True, blank=True) class Meta: constraints = [ diff --git a/src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg b/src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg new file mode 100644 index 0000000..770d825 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 39584b7..7191462 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -530,3 +530,66 @@ class TarotCardCautionsTest(TestCase): for caution in schizo.cautions: self.assertIn("reverse", caution) self.assertNotIn("transform", caution) + + +# ── SigReservation ready gate ───────────────────────────────────────────────── + +class SigReservationReadyGateTest(TestCase): + """SigReservation.ready and countdown_remaining fields.""" + + def setUp(self): + self.earthman = DeckVariant.objects.get(slug="earthman") + owner = User.objects.create(email="owner@test.io") + room = Room.objects.create(name="R", owner=owner) + card = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11 + ) + self.res = SigReservation.objects.create( + room=room, gamer=owner, card=card, role="PC", polarity="levity" + ) + + def test_ready_defaults_to_false(self): + self.assertFalse(self.res.ready) + + def test_countdown_remaining_defaults_to_none(self): + self.assertIsNone(self.res.countdown_remaining) + + def test_ready_can_be_set_true(self): + self.res.ready = True + self.res.save() + self.res.refresh_from_db() + self.assertTrue(self.res.ready) + + def test_countdown_remaining_can_be_saved(self): + self.res.countdown_remaining = 7 + self.res.save() + self.res.refresh_from_db() + self.assertEqual(self.res.countdown_remaining, 7) + + +# ── Room SKY_SELECT status ──────────────────────────────────────────────────── + +class RoomSkySelectStatusTest(TestCase): + """Room.SKY_SELECT constant and sig_select_started_at field.""" + + def setUp(self): + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="R", owner=owner) + + def test_sky_select_constant_value(self): + self.assertEqual(Room.SKY_SELECT, "SKY_SELECT") + + def test_sky_select_is_valid_table_status_choice(self): + choices = [c[0] for c in Room.TABLE_STATUS_CHOICES] + self.assertIn(Room.SKY_SELECT, choices) + + def test_sig_select_started_at_defaults_to_none(self): + self.assertIsNone(self.room.sig_select_started_at) + + def test_sig_select_started_at_can_be_set(self): + from django.utils import timezone + now = timezone.now() + self.room.sig_select_started_at = now + self.room.save() + self.room.refresh_from_db() + self.assertIsNotNone(self.room.sig_select_started_at) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 16798f2..7dba1cb 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -1306,3 +1306,307 @@ class SigReserveViewTest(TestCase): args, kwargs = mock_notify.call_args self.assertEqual(args[1], self.card.pk) # card_id must not be None self.assertFalse(kwargs['reserved']) # reserved=False + + +# ── sig_ready view ──────────────────────────────────────────────────────────── + +def _make_levity_reservations(room, gamers, earthman, ready=False): + """Create SigReservations for the three levity gamers (PC, NC, SC). + Returns the three reservations in PC→NC→SC order.""" + cards = [ + TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n) + for n in (11, 12, 13) + ] + roles = ["PC", "NC", "SC"] + # gamers[0]=PC, gamers[1]=NC, gamers[3]=SC + gamer_indices = [0, 1, 3] + reservations = [] + for gamer_idx, role, card in zip(gamer_indices, roles, cards): + seat = TableSeat.objects.get(room=room, role=role) + res = SigReservation.objects.create( + room=room, gamer=gamers[gamer_idx], card=card, + role=role, polarity="levity", seat=seat, ready=ready, + ) + reservations.append(res) + return reservations + + +class SigReadyViewTest(TestCase): + """sig_ready — toggle ready/unready for the polarity-room countdown.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman) + self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id}) + + def _post(self, action="ready", seconds_remaining=None, client=None): + c = client or self.client + data = {"action": action} + if seconds_remaining is not None: + data["seconds_remaining"] = seconds_remaining + return c.post(self.url, data=data) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_sig_ready_requires_login(self): + self.client.logout() + response = self._post() + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_sig_ready_requires_seated_gamer(self): + outsider = User.objects.create(email="outsider@test.io") + outsider_client = self.client.__class__() + outsider_client.force_login(outsider) + response = self._post(client=outsider_client) + self.assertEqual(response.status_code, 403) + + def test_sig_ready_wrong_phase_returns_400(self): + self.room.table_status = Room.ROLE_SELECT + self.room.save() + response = self._post() + self.assertEqual(response.status_code, 400) + + def test_sig_ready_without_reservation_returns_400(self): + """Can't go ready without an OK'd card.""" + SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete() + response = self._post() + self.assertEqual(response.status_code, 400) + + # ── happy-path ready ────────────────────────────────────────────────── + + def test_sig_ready_sets_ready_true_on_reservation(self): + self._post(action="ready") + res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0]) + self.assertTrue(res.ready) + + def test_sig_ready_returns_200(self): + response = self._post(action="ready") + self.assertEqual(response.status_code, 200) + + # ── unready ────────────────────────────────────────────────────────── + + def test_sig_unready_sets_ready_false(self): + self.reservations[0].ready = True + self.reservations[0].save() + self._post(action="unready") + res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0]) + self.assertFalse(res.ready) + + def test_sig_unready_when_not_ready_is_harmless(self): + response = self._post(action="unready") + self.assertEqual(response.status_code, 200) + + # ── countdown mechanics ─────────────────────────────────────────────── + + def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self): + """When all three levity gamers are ready, countdown_start broadcasts.""" + # Make NC and SC ready first + for res in self.reservations[1:]: + res.ready = True + res.save() + # PC (founder) goes ready — triggers all-three condition + with patch("apps.epic.views._notify_countdown_start") as mock_notify: + self._post(action="ready") + mock_notify.assert_called_once() + args = mock_notify.call_args[0] + self.assertIn("levity", args) # polarity in call + + def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self): + self.reservations[1].ready = True + self.reservations[1].save() + with patch("apps.epic.views._notify_countdown_start") as mock_notify: + self._post(action="ready") + mock_notify.assert_not_called() + + def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self): + for res in self.reservations: + res.ready = True + res.save() + self._post(action="unready", seconds_remaining=7) + for res in self.reservations: + res.refresh_from_db() + self.assertEqual(res.countdown_remaining, 7) + + def test_sig_unready_broadcasts_countdown_cancel(self): + for res in self.reservations: + res.ready = True + res.save() + with patch("apps.epic.views._notify_countdown_cancel") as mock_notify: + self._post(action="unready", seconds_remaining=7) + mock_notify.assert_called_once() + + def test_sig_ready_uses_saved_seconds_for_countdown_restart(self): + """If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12.""" + for res in self.reservations: + res.ready = True + res.countdown_remaining = 7 + res.save() + # One unreadied; now goes ready again — all 3 ready → start from 7 + self.reservations[0].ready = False + self.reservations[0].save() + with patch("apps.epic.views._notify_countdown_start") as mock_notify: + self._post(action="ready") + mock_notify.assert_called_once() + args, kwargs = mock_notify.call_args + seconds_sent = kwargs.get("seconds") or args[1] + self.assertEqual(seconds_sent, 7) + + +# ── sig_confirm view ────────────────────────────────────────────────────────── + +def _make_gravity_reservations(room, gamers, earthman, ready=False): + """Create SigReservations for the three gravity gamers (EC, AC, BC).""" + cards = [ + TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n) + for n in (11, 12, 13) + ] + roles = ["EC", "AC", "BC"] + # gamers[2]=EC, gamers[4]=AC, gamers[5]=BC + gamer_indices = [2, 4, 5] + reservations = [] + for gamer_idx, role, card in zip(gamer_indices, roles, cards): + seat = TableSeat.objects.get(room=room, role=role) + res = SigReservation.objects.create( + room=room, gamer=gamers[gamer_idx], card=card, + role=role, polarity="gravity", seat=seat, ready=ready, + ) + reservations.append(res) + return reservations + + +class SigConfirmViewTest(TestCase): + """sig_confirm — finalize polarity group once countdown reaches zero.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + # All three levity gamers are ready + self.lev_res = _make_levity_reservations( + self.room, self.gamers, self.earthman, ready=True + ) + # founder (PC) is already logged in from _full_sig_setUp + self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id}) + + def _post(self, polarity="levity", client=None): + c = client or self.client + return c.post(self.url, data={"polarity": polarity}) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_sig_confirm_requires_login(self): + self.client.logout() + response = self._post() + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_sig_confirm_requires_seated_gamer(self): + outsider = User.objects.create(email="outsider@test.io") + outsider_client = self.client.__class__() + outsider_client.force_login(outsider) + response = self._post(client=outsider_client) + self.assertEqual(response.status_code, 403) + + def test_sig_confirm_wrong_phase_returns_400(self): + self.room.table_status = Room.ROLE_SELECT + self.room.save() + response = self._post() + self.assertEqual(response.status_code, 400) + + def test_sig_confirm_not_all_polarity_ready_returns_400(self): + """If any of the three in the polarity group isn't ready, reject.""" + self.lev_res[1].ready = False + self.lev_res[1].save() + response = self._post() + self.assertEqual(response.status_code, 400) + + # ── happy-path ──────────────────────────────────────────────────────── + + def test_sig_confirm_sets_significator_on_seats_from_reservations(self): + self._post() + for res in self.lev_res: + seat = TableSeat.objects.get(room=self.room, role=res.role) + self.assertEqual(seat.significator, res.card) + + def test_sig_confirm_returns_200(self): + response = self._post() + self.assertEqual(response.status_code, 200) + + def test_sig_confirm_broadcasts_polarity_room_done(self): + with patch("apps.epic.views._notify_polarity_room_done") as mock_notify: + self._post() + mock_notify.assert_called_once() + args = mock_notify.call_args[0] + self.assertIn("levity", args) + + def test_sig_confirm_is_idempotent_if_significators_already_set(self): + """Second call from another browser returns 200 without re-running logic.""" + self._post() + response = self._post() + self.assertEqual(response.status_code, 200) + + # ── both polarities done ────────────────────────────────────────────── + + def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self): + """After both levity and gravity confirm, pick_sky_available fires.""" + # Pre-set gravity seats to already have significators (simulating earlier confirm) + grav_cards = [ + TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n) + for n in (11, 12, 13) + ] + for role, card in zip(["EC", "AC", "BC"], grav_cards): + seat = TableSeat.objects.get(room=self.room, role=role) + seat.significator = card + seat.save() + with patch("apps.epic.views._notify_pick_sky_available") as mock_notify: + self._post(polarity="levity") + mock_notify.assert_called_once() + + def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self): + grav_cards = [ + TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n) + for n in (11, 12, 13) + ] + for role, card in zip(["EC", "AC", "BC"], grav_cards): + seat = TableSeat.objects.get(room=self.room, role=role) + seat.significator = card + seat.save() + self._post(polarity="levity") + self.room.refresh_from_db() + self.assertEqual(self.room.table_status, Room.SKY_SELECT) + + def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self): + with patch("apps.epic.views._notify_pick_sky_available") as mock_notify: + self._post(polarity="levity") + mock_notify.assert_not_called() + + +# ── SKY_SELECT rendering ────────────────────────────────────────────────────── + +class PickSkyRenderingTest(TestCase): + """Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2.""" + + def setUp(self): + self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) + self.room.table_status = Room.SKY_SELECT + self.room.save() + self.sig_card = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11 + ) + pc_seat = TableSeat.objects.get(room=self.room, role="PC") + pc_seat.significator = self.sig_card + pc_seat.save() + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) + + def test_pick_sky_btn_present_in_sky_select_phase(self): + response = self.client.get(self.url) + self.assertContains(response, "id_pick_sky_btn") + + def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self): + response = self.client.get(self.url) + self.assertContains(response, "tray-sig-card") + + def test_pick_sky_btn_absent_during_sig_select(self): + self.room.table_status = Room.SIG_SELECT + self.room.save() + response = self.client.get(self.url) + self.assertNotContains(response, "id_pick_sky_btn") diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 0507cf6..dd556e3 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -17,6 +17,8 @@ urlpatterns = [ path('room//select-role', views.select_role, name='select_role'), path('room//select-sig', views.select_sig, name='select_sig'), path('room//sig-reserve', views.sig_reserve, name='sig_reserve'), + path('room//sig-ready', views.sig_ready, name='sig_ready'), + path('room//sig-confirm', views.sig_confirm, name='sig_confirm'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index dfe4772..aaf8bc9 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -93,6 +93,34 @@ def _notify_sig_reserved(room_id, card_id, role, reserved): ) +def _notify_countdown_start(room_id, polarity, *, seconds): + async_to_sync(get_channel_layer().group_send)( + f'cursors_{room_id}_{polarity}', + {'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds}, + ) + + +def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining): + async_to_sync(get_channel_layer().group_send)( + f'cursors_{room_id}_{polarity}', + {'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining}, + ) + + +def _notify_polarity_room_done(room_id, polarity): + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'polarity_room_done', 'polarity': polarity}, + ) + + +def _notify_pick_sky_available(room_id): + async_to_sync(get_channel_layer().group_send)( + f'room_{room_id}', + {'type': 'pick_sky_available'}, + ) + + SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} _SIG_SEAT_ORDERING = Case( @@ -260,6 +288,10 @@ def _role_select_context(room, user): "gate_positions": _gate_positions(room), "slots": room.gate_slots.order_by("slot_number"), } + # 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 + if room.table_status == Room.SIG_SELECT: user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None user_role = user_seat.role if user_seat else None @@ -647,6 +679,118 @@ def sig_reserve(request, room_id): return HttpResponse(status=200) +@login_required +def sig_ready(request, room_id): + """Toggle ready/unready for the polarity-room countdown. + POST body: action=ready|unready [, seconds_remaining=] + """ + if request.method != "POST": + return HttpResponse(status=405) + room = Room.objects.get(id=room_id) + if room.table_status != Room.SIG_SELECT: + return HttpResponse(status=400) + user_seat = _canonical_user_seat(room, request.user) + if user_seat is None: + return HttpResponse(status=403) + + action = request.POST.get("action", "ready") + reservation = SigReservation.objects.filter(room=room, gamer=request.user).first() + + if action == "ready": + if reservation is None: + return HttpResponse(status=400) + reservation.ready = True + reservation.save(update_fields=["ready"]) + + # Check if all three in this polarity are now ready + polarity = reservation.polarity + polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES + ready_count = SigReservation.objects.filter( + room=room, polarity=polarity, ready=True + ).count() + if ready_count == 3: + # Use saved countdown_remaining if a pause was recorded, else 12 + saved = SigReservation.objects.filter( + room=room, polarity=polarity + ).exclude(countdown_remaining__isnull=True).values_list( + "countdown_remaining", flat=True + ).first() + seconds = saved if saved is not None else 12 + _notify_countdown_start(room_id, polarity, seconds=seconds) + + else: # unready + if reservation is not None: + reservation.ready = False + reservation.save(update_fields=["ready"]) + polarity = reservation.polarity + + # Save remaining seconds on all polarity reservations + try: + seconds_remaining = int(request.POST.get("seconds_remaining", 12)) + except (TypeError, ValueError): + seconds_remaining = 12 + SigReservation.objects.filter(room=room, polarity=polarity).update( + countdown_remaining=seconds_remaining + ) + _notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining) + + return HttpResponse(status=200) + + +@login_required +def sig_confirm(request, room_id): + """Client posts this when the polarity-room countdown reaches zero. + POST body: polarity=levity|gravity + Sets significators on the three seats and broadcasts polarity_room_done. + When both polarities are confirmed, broadcasts pick_sky_available and + transitions the room to SKY_SELECT. + """ + if request.method != "POST": + return HttpResponse(status=405) + room = Room.objects.get(id=room_id) + if room.table_status != Room.SIG_SELECT: + return HttpResponse(status=400) + user_seat = _canonical_user_seat(room, request.user) + if user_seat is None: + return HttpResponse(status=403) + + seat_polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY + polarity = request.POST.get("polarity", seat_polarity) + polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES + + # Idempotency: if all seats in this polarity already have significators, skip + already_done = not room.table_seats.filter( + role__in=polarity_roles, significator__isnull=True + ).exists() + if already_done: + return HttpResponse(status=200) + + # Guard: all three must be ready + ready_reservations = list( + SigReservation.objects.filter(room=room, polarity=polarity, ready=True) + .select_related("seat", "card") + ) + if len(ready_reservations) < 3: + return HttpResponse(status=400) + + # Set significators from reservations + for res in ready_reservations: + if res.seat: + res.seat.significator = res.card + res.seat.save(update_fields=["significator"]) + + _notify_polarity_room_done(room_id, polarity) + + # Check if both polarities are now confirmed + all_done = not room.table_seats.filter(significator__isnull=True).exists() + if all_done: + room.table_status = Room.SKY_SELECT + room.save(update_fields=["table_status"]) + _notify_pick_sky_available(room_id) + + return HttpResponse(status=200) + + @login_required def select_sig(request, room_id): if request.method != "POST": diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py index 11e16aa..112b5d5 100644 --- a/src/functional_tests/test_room_sig_select.py +++ b/src/functional_tests/test_room_sig_select.py @@ -370,3 +370,389 @@ class SigSelectThemeTest(FunctionalTest): )) corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence") self.assertEqual(corr.text, "") + + +# ── TAKE SIG / WAIT NO — ready gate ────────────────────────────────────────── +# +# TAKE SIG (.btn.btn-primary) appears at the bottom-left corner of the card +# stage preview once a gamer has clicked OK on a card (SigReservation exists). +# Clicking it sets the gamer's status to ready and changes the btn to WAIT NO. +# WAIT NO cancels the ready status and reverts back to TAKE SIG. +# +# When all three gamers in a polarity WS room are ready, a 12-second countdown +# starts. Any WAIT NO during the countdown cancels it; the saved remaining time +# is resumed when all three are ready again. When the countdown completes +# (client POSTs sig_confirm) the polarity group returns to the table hex. +# When both polarity groups have confirmed, PICK SKY btn appears in the hex +# center for all six gamers. +# +# ───────────────────────────────────────────────────────────────────────────── + + +class SigReadyGateTest(FunctionalTest): + """Single-browser tests for TAKE SIG / WAIT NO btn.""" + + 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"} + ) + + def _setup_sig_room(self): + emails = [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ] + founder, _ = User.objects.get_or_create(email=emails[0]) + room = Room.objects.create(name="Ready Gate Test", owner=founder) + _fill_room_via_orm(room, emails) + _assign_all_roles(room) + return room + + def _click_ok_on_any_card(self): + """Click the first sig card to stage it, then click OK.""" + card = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card") + ) + card.click() + ok_btn = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-ok-btn") + ) + ok_btn.click() + + # ── SRG1: TAKE SIG btn not visible before OK ──────────────────────── # + + def test_take_sig_btn_not_visible_before_ok_click(self): + """TAKE SIG must be absent until the gamer has OK'd a card.""" + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + take_sig_btns = self.browser.find_elements(By.ID, "id_take_sig_btn") + self.assertEqual(len(take_sig_btns), 0) + + # ── SRG2: TAKE SIG btn appears after OK ──────────────────────────── # + + def test_take_sig_btn_appears_after_ok_click(self): + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + self._click_ok_on_any_card() + + take_sig_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_take_sig_btn") + ) + self.assertIn("TAKE SIG", take_sig_btn.text.upper()) + + # ── SRG3: TAKE SIG → WAIT NO ─────────────────────────────────────── # + + def test_take_sig_btn_becomes_wait_no_after_click(self): + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + self._click_ok_on_any_card() + + take_sig_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_take_sig_btn") + ) + take_sig_btn.click() + + wait_no_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_take_sig_btn") + ) + self.assertIn("WAIT NO", wait_no_btn.text.upper()) + + # ── SRG4: WAIT NO → TAKE SIG ─────────────────────────────────────── # + + def test_wait_no_reverts_to_take_sig(self): + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + self._click_ok_on_any_card() + + btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_take_sig_btn") + ) + btn.click() # → WAIT NO + self.wait_for(lambda: "WAIT NO" in self.browser.find_element( + By.ID, "id_take_sig_btn").text.upper() + ) + btn = self.browser.find_element(By.ID, "id_take_sig_btn") + btn.click() # → TAKE SIG again + + reverted = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_take_sig_btn") + ) + self.assertIn("TAKE SIG", reverted.text.upper()) + + +@tag("channels") +class SigReadyCountdownChannelsTest(ChannelsFunctionalTest): + """Multi-browser WebSocket tests for the polarity-room countdown and PICK SKY.""" + + 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"} + ) + + def _make_browser_for(self, email): + session_key = create_pre_authenticated_session(email) + options = webdriver.FirefoxOptions() + if os.environ.get("HEADLESS"): + options.add_argument("--headless") + b = webdriver.Firefox(options=options) + b.get(self.live_server_url + "/404_no_such_url/") + b.add_cookie(dict( + name=django_settings.SESSION_COOKIE_NAME, + value=session_key, + path="/", + )) + return b + + def _setup_sig_select_room(self): + emails = [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ] + founder, _ = User.objects.get_or_create(email=emails[0]) + room = Room.objects.create(name="Countdown Test", owner=founder) + _fill_room_via_orm(room, emails) + _assign_all_roles(room) + return room, emails + + # ── SRG5: countdown appears when all three polarity ready ─────────── # + + @tag("channels") + def test_countdown_element_appears_when_all_three_levity_gamers_ready(self): + """When PC, NC, and SC each click TAKE SIG the countdown becomes visible.""" + room, emails = self._setup_sig_select_room() + levity_emails = [emails[0], emails[1], emails[3]] # PC, NC, SC + browsers = [] + try: + for email in levity_emails: + b = self._make_browser_for(email) + b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + browsers.append(b) + + # Each levity gamer OK's a card then clicks TAKE SIG + for b in browsers: + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-card").click() + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click() + self.wait_for( + lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b + ) + b.find_element(By.ID, "id_take_sig_btn").click() + + # All three browsers should now see the countdown + for b in browsers: + self.wait_for( + lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b + ) + finally: + for b in browsers: + b.quit() + + # ── SRG6: countdown disappears when WAIT NO clicked ──────────────── # + + @tag("channels") + def test_countdown_disappears_when_any_levity_gamer_clicks_wait_no(self): + """Any WAIT NO during the countdown cancels it for all three browsers.""" + room, emails = self._setup_sig_select_room() + levity_emails = [emails[0], emails[1], emails[3]] + browsers = [] + try: + for email in levity_emails: + b = self._make_browser_for(email) + b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + browsers.append(b) + + # All go ready + for b in browsers: + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-card").click() + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click() + self.wait_for( + lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b + ) + b.find_element(By.ID, "id_take_sig_btn").click() + + # Confirm countdown started for all + for b in browsers: + self.wait_for( + lambda: b.find_element(By.ID, "id_sig_countdown"), browser=b + ) + + # PC clicks WAIT NO + browsers[0].find_element(By.ID, "id_take_sig_btn").click() + + # Countdown element should disappear for all three + for b in browsers: + self.wait_for( + lambda: len(b.find_elements(By.ID, "id_sig_countdown")) == 0, + browser=b, + ) + finally: + for b in browsers: + b.quit() + + # ── SRG7: PICK SKY btn appears after both polarity groups confirm ─── # + + @tag("channels") + def test_pick_sky_btn_appears_in_hex_after_both_groups_confirm(self): + """Once both levity and gravity countdowns complete, all six browsers + see the PICK SKY btn in the table hex center.""" + # This test drives the full flow end-to-end but uses ORM shortcuts + # to set all-ready state for one polarity, letting the other complete + # via the UI, to keep execution time manageable. + room, emails = self._setup_sig_select_room() + # Pre-confirm gravity via ORM: set significators on EC/AC/BC seats + from apps.epic.models import TarotCard, DeckVariant + earthman = DeckVariant.objects.get(slug="earthman") + grav_roles = ["EC", "AC", "BC"] + grav_suits = ["GRAILS", "BLADES", "CROWNS"] + for role, suit in zip(grav_roles, grav_suits): + card = TarotCard.objects.get( + deck_variant=earthman, arcana="MIDDLE", suit=suit, number=11 + ) + seat = room.table_seats.get(role=role) + seat.significator = card + seat.save() + + levity_emails = [emails[0], emails[1], emails[3]] + browsers = [] + try: + for email in levity_emails: + b = self._make_browser_for(email) + b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + browsers.append(b) + + # All levity gamers OK and TAKE SIG + for b in browsers: + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-card").click() + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click() + self.wait_for( + lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b + ) + b.find_element(By.ID, "id_take_sig_btn").click() + + # Wait for countdown to expire or be confirmed; PICK SKY appears in hex + for b in browsers: + self.wait_for( + lambda: b.find_element(By.ID, "id_pick_sky_btn"), browser=b + ) + finally: + for b in browsers: + b.quit() + + # ── SRG8: first-done group sees waiting message ───────────────────── # + + @tag("channels") + def test_first_done_polarity_sees_other_group_settling_message(self): + """After levity confirms but gravity hasn't yet, levity gamers see + 'Gravity settling . . .' on the dormant hex.""" + room, emails = self._setup_sig_select_room() + levity_emails = [emails[0], emails[1], emails[3]] + browsers = [] + try: + for email in levity_emails: + b = self._make_browser_for(email) + b.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + browsers.append(b) + + for b in browsers: + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-card"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-card").click() + self.wait_for( + lambda: b.find_element(By.CSS_SELECTOR, ".sig-ok-btn"), browser=b + ) + b.find_element(By.CSS_SELECTOR, ".sig-ok-btn").click() + self.wait_for( + lambda: b.find_element(By.ID, "id_take_sig_btn"), browser=b + ) + b.find_element(By.ID, "id_take_sig_btn").click() + + # Wait for levity confirm → hex revealed, waiting message visible + for b in browsers: + self.wait_for( + lambda: "settling" in b.find_element( + By.ID, "id_hex_waiting_msg" + ).text.lower(), + browser=b, + ) + finally: + for b in browsers: + b.quit() + + +# ── SKY OVERLAY (natal wheel) — DEFERRED / PENDING PYSWISS ────────────────── +# +# These FTs outline the sky overlay behavior but are left as stubs. +# The sky overlay will be built after the PySwiss microservice (step 18) +# and the D3 natal wheel implementation. A prototype already exists and +# will be reviewed before these tests are filled in. +# +# class PickSkyTrayFlowTest(FunctionalTest): +# +# def test_pick_sky_btn_opens_tray_with_sig_card_in_slot_2(self): +# """Clicking PICK SKY opens #id_tray; tray cell 2 shows the gamer's +# sig card icon (Blank.svg placeholder until card-specific icons land).""" +# ... +# +# def test_tray_close_dismisses_sig_overlay_and_reveals_hex(self): +# """After tray closes the sig select modal is gone and the table hex +# is visible again.""" +# ... +# +# def test_sky_overlay_appears_over_hex_after_tray_closes(self): +# """The sky overlay (#id_sky_overlay) appears over the hex once the +# tray animation completes.""" +# ... +# +# def test_sky_overlay_prompts_for_input_date(self): +# """The sky overlay contains a date input field for natal wheel +# calculation via the PySwiss microservice API.""" +# ... +# +# def test_sky_overlay_renders_natal_wheel_for_given_date(self): +# """Submitting a date triggers a D3-drawn natal wheel (pyswisseph +# data). Each house/planet is individually navigable.""" +# ... +# +# def test_sky_overlay_accessible_during_play_for_timeframe_changes(self): +# """During IN_GAME phase a gamer can reopen the sky overlay to change +# the timeframe or check aspects presiding over the current scene.""" +# ... +# +# ───────────────────────────────────────────────────────────────────────────── diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 3555046..e7de035 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -34,6 +34,9 @@ {% endif %} {% endif %} + {% if room.table_status == "SKY_SELECT" %} + PICKSKY + {% endif %} @@ -73,7 +76,7 @@ - {% if my_tray_role %}{% else %}{% endif %}{% for i in "2345678" %}{% endfor %} + {% if my_tray_role %}{% else %}{% endif %}{% if my_tray_sig %}{% else %}{% endif %}{% for i in "345678" %}{% endfor %} {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %}