From 4417b8c972b4b3539219e382210f97e73e074c82 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 20 May 2026 09:47:47 -0400 Subject: [PATCH] =?UTF-8?q?My=20Sea=20iter=206c:=20bud-btn=20invite=20stub?= =?UTF-8?q?=20+=20`#id=5Fmy=5Fsea=5Fmenu`=20gear=20(NVM-only,=20%applet-me?= =?UTF-8?q?nu-styled,=20on=20both=20/gameboard/my-sea/=20and=20the=20gatek?= =?UTF-8?q?eeper)=20+=20PAID=20DRAW=20now=20deletes=20the=20row=20and=20re?= =?UTF-8?q?directs=20to=20`=3Fphase=3Dpicker`=20so=20the=20user=20drops=20?= =?UTF-8?q?straight=20into=20picking=20cards=20instead=20of=20looping=20ba?= =?UTF-8?q?ck=20to=20GATE=20VIEW=20=E2=80=94=20Sprint=205=20iter=206c=20of?= =?UTF-8?q?=20My=20Sea=20roadmap=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled fix for the PAID-DRAW-loops-to-GATE-VIEW bug surfaced 2026-05-20 in live testing: previously the view reset `created_at = now()` + cleared the hand, but the row's continued existence meant `quota_spent=True` on the next render → landing rendered GATE VIEW → user clicked it → back to gatekeeper → loop. Now PAID DRAW does `active_draw.delete()` after debiting the token + then redirects to `/gameboard/my-sea/?phase=picker`. The my_sea view honors `?phase=picker` (only when no active_draw exists — can't bypass post-DEL GATE VIEW) by forcing `show_picker=True` so the user lands in the picker ready to draw. First card draw creates a fresh row w. fresh `created_at`, starting the new 24h quota cycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../gameboard/tests/integrated/test_views.py | 75 ++++++++++----- src/apps/gameboard/urls.py | 1 + src/apps/gameboard/views.py | 65 ++++++++++--- src/functional_tests/test_game_my_sea.py | 95 +++++++++++++++++++ src/static_src/scss/_applets.scss | 13 ++- .../_partials/_my_sea_bud_panel.html | 32 +++++++ .../gameboard/_partials/_my_sea_gear.html | 9 ++ src/templates/apps/gameboard/my_sea.html | 4 + src/templates/apps/gameboard/my_sea_gate.html | 14 +++ 9 files changed, 267 insertions(+), 41 deletions(-) create mode 100644 src/templates/apps/gameboard/_partials/_my_sea_bud_panel.html create mode 100644 src/templates/apps/gameboard/_partials/_my_sea_gear.html diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 63a28b1..adfd1fa 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1563,32 +1563,22 @@ class MySeaPaidDrawViewTest(TestCase): self.client.post(self.url) self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists()) - def test_paid_draw_clears_deposit_fields_and_resets_created_at(self): - old_created = self.draw.created_at - # Push old created_at back so reset is observable. - from datetime import timedelta + def test_paid_draw_deletes_active_draw_row(self): + # User-spec 2026-05-20: PAID DRAW commits the token + drops the row + # entirely so the user returns to a fresh "able-to-draw-now" state + # (instead of the buggy "row preserved → GATE VIEW loop" semantics). from apps.gameboard.models import MySeaDraw - MySeaDraw.objects.filter(pk=self.draw.pk).update( - created_at=old_created - timedelta(hours=12), - ) self.client.post(self.url) - self.draw.refresh_from_db() - self.assertIsNone(self.draw.deposit_token_id) - self.assertIsNone(self.draw.deposit_reserved_at) - self.assertGreater(self.draw.created_at, old_created - timedelta(hours=12)) + self.assertFalse(MySeaDraw.objects.filter(pk=self.draw.pk).exists()) - def test_paid_draw_resets_hand_to_empty(self): - # Even if hand is non-empty, PAID DRAW wipes it (fresh draw cycle). - from apps.epic.models import TarotCard - cards = list(TarotCard.objects.exclude(id=self.target.id)[:2]) - self.draw.hand = [ - {"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}, - {"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"}, - ] - self.draw.save(update_fields=["hand"]) - self.client.post(self.url) - self.draw.refresh_from_db() - self.assertEqual(self.draw.hand, []) + def test_paid_draw_redirects_to_my_sea_with_phase_picker(self): + # User-spec 2026-05-20: drop the user directly into the picker + # after PAID DRAW (no intermediate FREE-DRAW click). Encoded via + # `?phase=picker` query param so the my_sea view can short- + # circuit `show_picker` even when active_draw is now None. + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn("phase=picker", response["Location"]) def test_paid_draw_with_coin_sets_24h_cooldown_and_unequips(self): from datetime import timedelta @@ -1621,6 +1611,45 @@ class MySeaPaidDrawViewTest(TestCase): self.assertEqual(response.status_code, 302) +class MySeaPhasePickerQueryParamTest(TestCase): + """Sprint 6 iter 6c — `?phase=picker` query param forces picker phase + when no active_draw row exists (the just-after-PAID-DRAW state). + Without the param, no-active-draw users default to the FREE DRAW + landing. With it, they drop straight into the picker so they can + start drawing immediately (the token they just spent earns this).""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + self.user = User.objects.create(email="phase@test.io") + self.client.force_login(self.user) + self.target = personal_sig_cards(self.user)[0] + self.user.significator = self.target + self.user.save(update_fields=["significator"]) + + def test_no_param_lands_on_free_draw(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-phase="landing"') + self.assertContains(response, 'id="id_draw_sea_btn"') + + def test_phase_picker_param_forces_picker(self): + response = self.client.get(reverse("my_sea") + "?phase=picker") + self.assertContains(response, 'data-phase="picker"') + # Picker IS rendered (no inline style="display:none" on it). + self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"') + + def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self): + # Post-DEL state: active row w. empty hand → quota's spent, the + # query param shouldn't bypass GATE VIEW. Landing branch wins. + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + ) + response = self.client.get(reverse("my_sea") + "?phase=picker") + self.assertContains(response, 'data-phase="landing"') + self.assertContains(response, 'id="id_my_sea_gate_view_btn"') + + class SelectMySeaTokenTest(TestCase): """Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE excluded + COIN cooldown-respecting.""" diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index bbd583c..4eefe6f 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -20,5 +20,6 @@ urlpatterns = [ path('my-sea/insert', views.my_sea_insert_token, name='my_sea_insert_token'), path('my-sea/refund', views.my_sea_refund_token, name='my_sea_refund_token'), path('my-sea/paid-draw', views.my_sea_paid_draw, name='my_sea_paid_draw'), + path('my-sea/invite', views.my_sea_invite, name='my_sea_invite'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 07859cc..3b14458 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -219,7 +219,18 @@ def my_sea(request): # (the daily quota's spent already; landing's primary nav routes to # the upcoming gatekeeper). New users + post-24h users land on the # standard FREE DRAW landing. - show_picker = active_draw is not None and not hand_empty + # + # `?phase=picker` query param (set by PAID DRAW's redirect) forces + # the picker even when active_draw is None — the user just paid a + # token, so drop them straight into the picker rather than making + # them click FREE DRAW first. Only honored when active_draw is None + # (post-PAID-DRAW state); existing rows route through the normal + # logic above so the param can't accidentally bypass a GATE VIEW + # or empty-hand state. + phase_param = request.GET.get("phase") == "picker" + show_picker = (active_draw is not None and not hand_empty) or ( + active_draw is None and phase_param + ) quota_spent = active_draw is not None # any active row = quota committed # Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence. # `deposit_reserved` toggles the landing primary from GATE VIEW to @@ -453,11 +464,23 @@ def my_sea_refund_token(request): @login_required(login_url="/") @require_POST def my_sea_paid_draw(request): - """Commit the deposited token + reset the row for a fresh quota - cycle. The token is debited via `debit_my_sea_token` (FREE/TITHE - consumed; COIN 24h cooldown + unequipped; PASS no-op). Hand wiped, - `created_at` reset to now, deposit fields cleared. User redirects - back to /gameboard/my-sea/ ready to draw a fresh hand.""" + """Commit the deposited token + drop the active_draw row so the + user returns to a fresh "able-to-draw-now" state. Without the row, + `quota_spent` resolves to False on the next my-sea render → the + user can draw cards immediately (the token they just spent earns + them this 24h cycle's worth of draws). + + The token is debited via `debit_my_sea_token` (FREE/TITHE consumed; + COIN 24h cooldown + unequipped; PASS no-op). The row is then + deleted (rather than just reset) — user-spec 2026-05-20: keeping + the row but resetting created_at left `quota_spent=True` on the + next view, looping the user back to GATE VIEW. Delete sidesteps + that entirely. + + Redirects to /gameboard/my-sea/?phase=picker so the user lands + directly in the picker (skipping the FREE DRAW landing click). + """ + from django.urls import reverse from apps.lyric.models import Token active_draw = active_draw_for(request.user) if active_draw is None or active_draw.deposit_token_id is None: @@ -473,14 +496,28 @@ def my_sea_paid_draw(request): active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) return redirect("my_sea") debit_my_sea_token(request.user, token) - active_draw.hand = [] - active_draw.created_at = timezone.now() - active_draw.deposit_token_id = None - active_draw.deposit_reserved_at = None - active_draw.save(update_fields=[ - "hand", "created_at", "deposit_token_id", "deposit_reserved_at", - ]) - return redirect("my_sea") + active_draw.delete() + return redirect(reverse("my_sea") + "?phase=picker") + + +@login_required(login_url="/") +@require_POST +def my_sea_invite(request): + """Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper. + Async multi-user invite is deferred to a later sprint; this endpoint + just returns a Brief banner announcing "coming soon" so the bud-btn + panel has a non-broken success path.""" + from django.urls import reverse + return JsonResponse({ + "brief": { + "title": "Multiplayer my-sea", + "line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.", + "post_url": reverse("gameboard"), + "created_at": "", + "kind": "NUDGE", + }, + "recipient_display": (request.POST.get("recipient") or "").strip(), + }) def _my_sea_deck_data(user, exclude_id=None): diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index b48500d..33cb841 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -1561,3 +1561,98 @@ class MySeaBudBtnStubTest(FunctionalTest): lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner") ) self.assertIn("coming soon", brief.text.lower()) + + +class MySeaGearBtnTest(FunctionalTest): + """Sprint 6 iter 6c — `.gear-btn` on every my-sea page state + (landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE + deliberately omitted — gear is for "back out without committing"); + NVM nav-backs to /gameboard/ mirroring the room's gear-menu + convention. Same partial included on both /gameboard/my-sea/ and + /gameboard/my-sea/gate/ so visual + behavior is identical.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "gear@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + # Seed a quota row so the gatekeeper has an active_draw context. + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.gamer, spread="situation-action-outcome", + significator_id=self.gamer.significator_id, hand=[], + ) + + def test_gear_btn_renders_on_gatekeeper(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + gear = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-page .gear-btn" + ) + ) + # Menu wired via data-menu-target. + self.assertEqual( + gear.get_attribute("data-menu-target"), + "id_my_sea_menu", + ) + + def test_gear_btn_renders_on_landing_without_active_draw(self): + """User direction 2026-05-20 — gear stays on /gameboard/my-sea/ + even when no gatekeeper / no active_draw row exists. Fresh user + with a sig + no draw lands here + still sees the gear.""" + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.filter(user=self.gamer).delete() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-page .gear-btn" + ) + ) + + def test_gear_btn_opens_menu_with_nvm_only(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + gear = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-page .gear-btn" + ) + ) + gear.click() + menu = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_my_sea_menu") + ) + self.assertEqual(menu.value_of_css_property("display"), "block") + # NVM link present, DEL + BYE deliberately absent. + nvm = menu.find_element(By.CSS_SELECTOR, ".btn-cancel") + self.assertIn("/gameboard/", nvm.get_attribute("href")) + self.assertEqual( + len(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")), 0, + ) + self.assertEqual( + len(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")), 0, + ) + + def test_nvm_navigates_back_to_gameboard(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + gear = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-page .gear-btn" + ) + ) + gear.click() + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_my_sea_menu") + ) + self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel" + ).click() + self.wait_for( + lambda: self.assertRegex( + self.browser.current_url, r"/gameboard/$" + ) + ) diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index d0a8a95..b21bbb6 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -85,6 +85,7 @@ #id_post_menu { @extend %applet-menu; } #id_billboard_applet_menu { @extend %applet-menu; } #id_billscroll_menu { @extend %applet-menu; } +#id_my_sea_menu { @extend %applet-menu; } // Page-level gear buttons — fixed to viewport bottom-right .gameboard-page, @@ -93,7 +94,8 @@ .room-page, .post-page, .billboard-page, -.billscroll-page { +.billscroll-page, +.my-sea-page { > .gear-btn { position: fixed; bottom: 4.2rem; @@ -108,7 +110,8 @@ #id_wallet_applet_menu, #id_post_menu, #id_billboard_applet_menu, -#id_billscroll_menu { +#id_billscroll_menu, +#id_my_sea_menu { position: fixed; bottom: 6.6rem; right: 1rem; @@ -126,7 +129,8 @@ .room-page, .post-page, .billboard-page, - .billscroll-page { + .billscroll-page, + .my-sea-page { > .gear-btn { right: calc((var(--sidebar-w) - 3rem) / 2); bottom: 3.95rem; @@ -141,7 +145,8 @@ #id_room_menu, #id_post_menu, #id_billboard_applet_menu, - #id_billscroll_menu { + #id_billscroll_menu, + #id_my_sea_menu { right: calc((var(--sidebar-w) - 3rem) / 2); bottom: 6.6rem; top: auto; diff --git a/src/templates/apps/gameboard/_partials/_my_sea_bud_panel.html b/src/templates/apps/gameboard/_partials/_my_sea_bud_panel.html new file mode 100644 index 0000000..41fe1f0 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_bud_panel.html @@ -0,0 +1,32 @@ +{% load static %} +{# Sprint 6 iter 6c — bud-btn invite panel on the my-sea gatekeeper. #} +{# Mirrors `_bud_invite_panel.html` (room) but POSTs to a stub view — #} +{# real async multi-user invite is deferred to a future sprint, so OK #} +{# returns a 'Multiplayer my-sea coming soon' Brief banner. #} + + + +
+ + +
+ + + + + + diff --git a/src/templates/apps/gameboard/_partials/_my_sea_gear.html b/src/templates/apps/gameboard/_partials/_my_sea_gear.html new file mode 100644 index 0000000..bddadd7 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_gear.html @@ -0,0 +1,9 @@ +{# Sprint 6 iter 6c — gear-btn on /gameboard/my-sea/ + the gatekeeper. #} +{# NVM-only menu (no DEL, no BYE) — gear is the "back out without #} +{# committing" affordance; NVM nav-backs to /gameboard/ mirroring the #} +{# room's gear-menu convention. Rendered unconditionally (no active- #} +{# draw guard) so fresh users + post-DEL states still see it. #} + +{% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index e460b99..16945bc 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -900,5 +900,9 @@ {% endif %} {% endif %} + {# Sprint 6 iter 6c — gear-btn lives on every my-sea page state #} + {# (sign-gate / landing / picker). NVM-only menu mirrors the #} + {# gatekeeper's gear; "back out to /gameboard/" affordance. #} + {% include "apps/gameboard/_partials/_my_sea_gear.html" %} {% endblock content %} diff --git a/src/templates/apps/gameboard/my_sea_gate.html b/src/templates/apps/gameboard/my_sea_gate.html index c476e9e..d1a6034 100644 --- a/src/templates/apps/gameboard/my_sea_gate.html +++ b/src/templates/apps/gameboard/my_sea_gate.html @@ -82,5 +82,19 @@ + + {# Sprint 6 iter 6c — bud-btn invite stub (multiplayer-coming-soon) #} + {# + gear-btn NVM-only menu. Both render outside .gate-modal so the #} + {# bud-btn lives at viewport fixed position (per `_bud.scss`) and #} + {# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #} + {% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %} + {% include "apps/gameboard/_partials/_my_sea_gear.html" %} {% endblock content %} + +{% block scripts %} + {# Brief module — needed by _my_sea_bud_panel's OK handler so the #} + {# 'Multiplayer my-sea coming soon' banner shows on a successful #} + {# (stub) invite. #} + +{% endblock scripts %}