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