My Sea iter 6c: bud-btn invite stub + #id_my_sea_menu gear (NVM-only, %applet-menu-styled, on both /gameboard/my-sea/ and the gatekeeper) + PAID DRAW now deletes the row and redirects to ?phase=picker so the user drops straight into picking cards instead of looping back to GATE VIEW — Sprint 5 iter 6c of My Sea roadmap — TDD

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) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 09:47:47 -04:00
parent 1e37fe1475
commit 4417b8c972
9 changed files with 267 additions and 41 deletions

View File

@@ -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."""

View File

@@ -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'),
]

View File

@@ -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):

View File

@@ -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/$"
)
)

View File

@@ -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;

View File

@@ -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. #}
<button id="id_bud_btn" type="button" aria-label="Invite a friend">
<i class="fa-solid fa-handshake"></i>
</button>
<div id="id_bud_panel">
<input id="id_recipient"
name="recipient"
type="text"
placeholder="friend@example.com or username"
autocomplete="off">
<button id="id_bud_ok" type="button" class="btn btn-confirm">OK</button>
</div>
<div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
<script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
<script src="{% static 'apps/billboard/bud-btn.js' %}"></script>
<script>
bindBudBtn({
submitUrl: '{% url "my_sea_invite" %}',
autocompleteUrl: '{% url "billboard:search_buds" %}',
onSuccess: function (data) {
if (window.Brief && data.brief) Brief.showBanner(data.brief);
},
});
</script>

View File

@@ -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. #}
<div id="id_my_sea_menu" style="display:none">
<a href="{% url 'gameboard' %}" class="btn btn-cancel">NVM</a>
</div>
{% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %}

View File

@@ -900,5 +900,9 @@
</script>
{% 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" %}
</div>
{% endblock content %}

View File

@@ -82,5 +82,19 @@
</div>
</div>
</div>
{# 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" %}
</div>
{% 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. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
{% endblock scripts %}