diff --git a/src/apps/gameboard/management/__init__.py b/src/apps/gameboard/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/gameboard/management/commands/__init__.py b/src/apps/gameboard/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/gameboard/management/commands/delete_stale_my_sea_draws.py b/src/apps/gameboard/management/commands/delete_stale_my_sea_draws.py new file mode 100644 index 0000000..056bd49 --- /dev/null +++ b/src/apps/gameboard/management/commands/delete_stale_my_sea_draws.py @@ -0,0 +1,24 @@ +"""Hard-delete MySeaDraw rows older than FREE_DRAW_COOLDOWN_HOURS. + +The lazy cleanup inside `apps.gameboard.models.active_draw_for` already +prunes a user's stale rows on every view hit; this command is the cron +backstop for inactive accounts whose rows would otherwise sit forever. + +Run from cron (or invoke manually). No flags; idempotent. + +Usage: + python manage.py delete_stale_my_sea_draws +""" +from django.core.management.base import BaseCommand + +from apps.gameboard.models import MySeaDraw + + +class Command(BaseCommand): + help = "Delete MySeaDraw rows older than the free-draw cooldown window (24h)." + + def handle(self, *args, **options): + n = MySeaDraw.delete_stale() + self.stdout.write(self.style.SUCCESS( + f"Deleted {n} stale MySeaDraw row{'s' if n != 1 else ''}." + )) diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 9636388..f24606f 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -5,28 +5,45 @@ from django.utils import timezone FREE_DRAW_COOLDOWN_HOURS = 24 +# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS. +# Used by `is_hand_complete` to decide whether the picker UX is in +# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled) +# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state. +HAND_SIZE_BY_SPREAD = { + "past-present-future": 3, + "situation-action-outcome": 3, + "mind-body-spirit": 3, + "desire-obstacle-solution": 3, + "waite-smith": 6, + "escape-velocity": 6, +} + class MySeaDraw(models.Model): """Persisted Celtic-Cross-style tarot draw for the solo-user My Sea - feature. Each row is one locked hand by one user. + feature. Each row is the user's active draw — both the hand state + AND the 24h quota tracker. - Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side - persistence of the iter-4a client-side draw mechanic. - - Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window - (24h, irrespective of spread type). Subsequent draws within the - window are intended to be gated behind a token deposit at the My - Sea gatekeeper, which Sprint 6 will build. + Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the + iter-4b LOCK-HAND model. The row is created on the FIRST card draw + (manual or AUTO DRAW), not on a separate LOCK action. Quota is + committed at first-card moment + survives a DEL (DEL clears the + `hand` JSON but preserves the row — so `created_at` keeps running + the 24h clock + landing renders GATE VIEW instead of FREE DRAW + until the row expires). `hand` is an ordered list of position-dicts in draw order — Sprint 7's applet renders them left-to-right in that order. Each entry shape: {"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"} + For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD). + For DEL'd rows, `hand` is `[]` but `created_at` still anchors the + quota window. + `significator_id` + `significator_reversed` snapshot the user's sig - at lock time so a subsequent `User.significator = None` (via my-sign - DEL) doesn't invalidate the saved draw — per user spec, preserve the - old sig; any future draw uses whatever sig is current at that time. + at first-card-draw time so a subsequent `User.significator = None` + (via my-sign DEL) doesn't invalidate the saved draw. """ user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -60,16 +77,45 @@ class MySeaDraw(models.Model): timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) ) + @property + def is_hand_complete(self): + """True iff the hand has filled every position for its spread. + Drives the picker-form-col button state (AUTO DRAW → GATE VIEW + + DEL enable + FLIPs disable on the deck stacks).""" + return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0) + + @property + def is_hand_empty(self): + """True iff the hand carries no entries — the post-DEL state, + where the row stays as a quota tracker but the cards are gone.""" + return not self.hand + + @classmethod + def delete_stale(cls): + """Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS. + Returns the deletion count. + + Called lazily from `active_draw_for` on every view access (so + the cleanup naturally rides user traffic — no scheduler needed + for the common case) AND from the `delete_stale_my_sea_draws` + management command (cron backstop for inactive periods).""" + cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) + deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete() + return deleted + def active_draw_for(user): """Return the user's most-recent draw within the quota window, or - None. Used both for rendering the picker w. saved hand on page load - and for gating LOCK HAND POSTs. + None. Single source of truth for "does the user have an active draw" + (drives view branching: no-active → landing w. FREE DRAW; active w. + empty hand → landing w. GATE VIEW; active w. non-empty hand → picker). - Importing this helper rather than re-deriving the cutoff in every - caller keeps the 24h window a single-source-of-truth tied to - FREE_DRAW_COOLDOWN_HOURS.""" + Lazy stale-row cleanup: every call prunes the user's >24h rows, so + the DB doesn't accumulate one row per user per day. The user-spec + 'auto-delete all draws after 24hrs, whether or not the user has + deleted them' (2026-05-20) lands here w. no scheduler required.""" cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) + MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete() return MySeaDraw.objects.filter( user=user, created_at__gte=cutoff, ).order_by("-created_at").first() diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index fa80d52..f2bf267 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase): self.assertIn('data-position="loom">', html) self.assertIn('data-position="cross">', html) - def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self): + def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self): + # Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS + # transitions to GATE VIEW on completion. ID `id_sea_action_btn` + # is the single slot housing both states (label + `data-state` + # toggled by JS). User w. no active draw → AUTO DRAW label. response = self.client.get(reverse("my_sea")) html = response.content.decode() self.assertIn("sea-deck-stack--gravity", html) self.assertIn("sea-deck-stack--levity", html) - self.assertIn('id="id_sea_lock_hand"', html) + self.assertIn('id="id_sea_action_btn"', html) + self.assertIn('data-state="auto-draw"', html) + self.assertIn("AUTO", html) self.assertIn('id="id_sea_del"', html) self.assertIn("sea-reversal-hint", html) self.assertIn("25% reversals", html) @@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase): parsed = datetime.fromisoformat(body["next_free_draw_at"]) self.assertIsNotNone(parsed) - def test_lock_post_within_quota_window_returns_409(self): - # Second lock within 24h: the existing draw already occupies the - # quota; the server rejects rather than overwriting. + def test_lock_post_within_quota_upserts_same_row(self): + # Iter 4c — `/lock` is now an upsert (per-placement POST cadence). + # Second POST w. same spread updates the existing row's hand + # rather than 409'ing. Only one row exists per user per 24h. import json from apps.gameboard.models import MySeaDraw + from apps.epic.models import TarotCard + # First POST: 1-card partial hand + cards = list(TarotCard.objects.exclude(id=self.target.id)[:3]) + partial = { + "spread": "situation-action-outcome", + "hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}], + } + r1 = self.client.post( + self.url, data=json.dumps(partial), + content_type="application/json", + ) + self.assertEqual(r1.status_code, 200) + # Second POST: full 3-card hand (the SAME draw progressing). + full = self._build_payload() + r2 = self.client.post( + self.url, data=json.dumps(full), + content_type="application/json", + ) + self.assertEqual(r2.status_code, 200) + # Exactly one MySeaDraw row exists; hand is the latest full one. + rows = MySeaDraw.objects.filter(user=self.user) + self.assertEqual(rows.count(), 1) + self.assertEqual(len(rows.first().hand), 3) + + def test_lock_post_spread_mismatch_within_quota_returns_409(self): + # Spread is committed at first-card moment; switching to a + # different spread mid-quota-window is rejected. + import json + from apps.epic.models import TarotCard + cards = list(TarotCard.objects.exclude(id=self.target.id)[:3]) self.client.post( - self.url, data=json.dumps(self._build_payload()), + self.url, data=json.dumps({ + "spread": "situation-action-outcome", + "hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}], + }), content_type="application/json", ) response = self.client.post( - self.url, data=json.dumps(self._build_payload()), + self.url, data=json.dumps({ + "spread": "waite-smith", + "hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}], + }), content_type="application/json", ) self.assertEqual(response.status_code, 409) - self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1) + + def test_lock_post_returns_hand_complete_flag(self): + # Body includes `hand_complete` so the JS can decide whether to + # transition the picker into post-completion state (DEL enable, + # FLIPs disable, AUTO DRAW → GATE VIEW). + import json + partial = { + "spread": "situation-action-outcome", + "hand": self._build_payload()["hand"][:1], + } + r1 = self.client.post( + self.url, data=json.dumps(partial), + content_type="application/json", + ) + self.assertFalse(r1.json()["hand_complete"]) + r2 = self.client.post( + self.url, data=json.dumps(self._build_payload()), + content_type="application/json", + ) + self.assertTrue(r2.json()["hand_complete"]) def test_lock_post_empty_hand_returns_400(self): import json @@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase): class MySeaDeleteDrawViewTest(TestCase): - """Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw.""" + """Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND + but preserves the row so the 24h quota window keeps running.""" def setUp(self): from apps.epic.models import personal_sig_cards, TarotCard @@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 405) - def test_delete_post_clears_active_draw(self): + def test_delete_post_clears_hand_but_preserves_row(self): + # Iter 4c — DEL no longer deletes; the row stays as a 24h quota + # tracker. Hand JSON gets wiped + `created_at` preserved (so the + # landing renders GATE VIEW, not FREE DRAW, until the row expires). from apps.gameboard.models import MySeaDraw + original_created_at = self.draw.created_at response = self.client.post(self.url) self.assertIn(response.status_code, (200, 204, 302)) - self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists()) + self.draw.refresh_from_db() + self.assertEqual(self.draw.hand, []) + self.assertEqual(self.draw.created_at, original_created_at) + self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists()) def test_delete_post_scoped_to_user_does_not_touch_others(self): from apps.gameboard.models import MySeaDraw @@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase): hand=self.draw.hand, significator_id=self.target.id, ) self.client.post(self.url) - self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists()) + other_draw.refresh_from_db() + self.assertEqual(len(other_draw.hand), 3) # untouched def test_delete_post_idempotent_when_no_active_draw(self): # User deletes twice in a row — second call is a no-op, not a 500. + # First DEL clears hand. Second DEL finds the row w. empty hand + # already; just no-ops. self.client.post(self.url) response = self.client.post(self.url) self.assertIn(response.status_code, (200, 204, 302)) @@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase): response = self.client.get(reverse("my_sea")) self.assertNotContains(response, 'id="id_draw_sea_btn"') self.assertNotContains(response, 'data-phase="landing"') + + def test_complete_hand_renders_action_btn_as_gate_view(self): + # Iter 4c — server pre-renders the action btn label + data-state + # based on `hand_complete`. With the setUp's 3-card SAO hand, + # hand is complete → btn label is GATE VIEW. + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-state="gate-view"') + self.assertContains(response, "GATE") + self.assertContains(response, "VIEW") + + def test_complete_hand_picker_carries_locked_class(self): + # `.my-sea-picker--locked` is server-rendered for completed hands + # so the JS init seeds `_locked=true` w.o. waiting for the post- + # placement state transition (matters for hot reloads, bfcache). + response = self.client.get(reverse("my_sea")) + self.assertContains(response, "my-sea-picker--locked") + + def test_complete_hand_del_btn_is_not_disabled(self): + # DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete + # hand → DEL renders w.o. the disabled class (clicking opens the + # guard portal). + import re + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + m = re.search( + r']*id="id_sea_del"[^>]*class="([^"]*)"', html, + ) + self.assertIsNotNone(m) + self.assertNotIn("btn-disabled", m.group(1)) + + +class MySeaViewWithEmptyHandTest(TestCase): + """Sprint 5 iter 4c — view branch for an active draw w. empty hand + (the post-DEL state, where the quota row stays as a 24h tracker but + the user's hand has been wiped). Landing renders w. GATE VIEW (NOT + FREE DRAW) as the primary nav.""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + from apps.gameboard.models import MySeaDraw + self.user = User.objects.create(email="empty@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"]) + # Active draw row but hand is empty — simulates the post-DEL state. + self.draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + ) + + def test_empty_hand_renders_landing_phase_not_picker(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-phase="landing"') + + def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'id="id_my_sea_gate_view_btn"') + self.assertNotContains(response, 'id="id_draw_sea_btn"') + + def test_empty_hand_gate_view_btn_links_to_gate_url(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, reverse("my_sea_gate")) + + def test_empty_hand_brief_banner_still_triggered(self): + # Quota's still committed (row exists, 24h clock still running) → + # the Brief banner is part of the saved-draw context, regardless + # of hand state. Informs the user when the next free draw is. + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'window._showFreeDrawLockedBrief("') + + +class MySeaViewWithPartialHandTest(TestCase): + """Sprint 5 iter 4c — view branch for an active draw w. mid-progress + hand (some slots filled, not yet complete). Picker renders w. the + partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled.""" + + def setUp(self): + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + self.user = User.objects.create(email="partial@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"]) + cards = list(TarotCard.objects.exclude(id=self.target.id)[:2]) + # SAO is a 3-position spread; partial = 2 cards drawn. + self.draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, + hand=[ + {"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}, + {"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"}, + ], + ) + + def test_partial_hand_renders_picker_phase(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-phase="picker"') + + def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self): + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-state="auto-draw"') + self.assertContains(response, "AUTO") + + def test_partial_hand_del_btn_carries_btn_disabled(self): + import re + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + m = re.search( + r']*id="id_sea_del"[^>]*class="([^"]*)"', html, + ) + self.assertIsNotNone(m) + self.assertIn("btn-disabled", m.group(1)) + + def test_partial_hand_picker_does_NOT_carry_locked_class(self): + # Hand is mid-progress; locked class only applies on completion. + import re + response = self.client.get(reverse("my_sea")) + html = response.content.decode() + m = re.search(r'
` block can render @@ -234,11 +254,14 @@ def my_sea(request): _my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None) if user_has_sig else {"levity": [], "gravity": []} ), - # Iter 4b + # Iter 4b / 4c "active_draw": active_draw, "saved_hand": saved_hand, "saved_by_position": saved_by_position, "next_free_draw_at": next_free_draw_at, + "hand_complete": hand_complete, + "show_picker": show_picker, + "quota_spent": quota_spent, "page_class": "page-gameboard page-my-sea", }) @@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw): @login_required(login_url="/") @require_POST def my_sea_lock(request): - """Persist the user's just-drawn hand as a `MySeaDraw` row. + """Upsert the user's draw hand state. Sprint 5 iter 4c refactor — + fires on every card placement (manual FLIP or AUTO DRAW completion) + rather than only on a discrete LOCK HAND action. Body: JSON `{"spread": "", "hand": [{position, card_id, reversed, - polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success, - 400 for malformed payload, 409 if the user is still within the free- - draw cooldown window (existing active draw).""" + polarity}, ...]}` — `hand` is the current FULL state (partial OK + for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete). + + Returns: + 200 `{ok, next_free_draw_at, hand_complete}` on success + 400 malformed payload or no sig + 409 spread differs from the user's already-active draw's spread + (the spread is locked at first-card moment; can't switch mid- + draw via a sneaky POST) + """ try: payload = json.loads(request.body.decode("utf-8") or "{}") except json.JSONDecodeError: @@ -272,10 +304,26 @@ def my_sea_lock(request): hand = payload.get("hand") if not spread or not isinstance(hand, list) or not hand: return JsonResponse({"error": "spread_and_hand_required"}, status=400) + if spread not in HAND_SIZE_BY_SPREAD: + return JsonResponse({"error": "unknown_spread"}, status=400) - if active_draw_for(request.user) is not None: - return JsonResponse({"error": "quota_active"}, status=409) + existing = active_draw_for(request.user) + if existing is not None: + # Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route + # through the gatekeeper but the endpoint stays permissive here). + # Spread-switch attempts get 409 — the spread is committed at + # first-card moment. + if existing.spread != spread: + return JsonResponse({"error": "spread_mismatch"}, status=409) + existing.hand = hand + existing.save(update_fields=["hand"]) + return JsonResponse({ + "ok": True, + "next_free_draw_at": existing.next_free_draw_at.isoformat(), + "hand_complete": existing.is_hand_complete, + }) + # First card draw → quota commit. Create the row. sig_id = request.user.significator_id if sig_id is None: return JsonResponse({"error": "no_significator"}, status=400) @@ -290,18 +338,35 @@ def my_sea_lock(request): return JsonResponse({ "ok": True, "next_free_draw_at": draw.next_free_draw_at.isoformat(), + "hand_complete": draw.is_hand_complete, }) @login_required(login_url="/") @require_POST def my_sea_delete(request): - """Delete the user's active draw — invoked by the DEL guard portal's - CONFIRM. Idempotent: a second call w. no active draw is a 204.""" - MySeaDraw.objects.filter(user=request.user).delete() + """Clear the user's active draw hand — preserves the `MySeaDraw` row + so the 24h quota window keeps running. Per user spec (2026-05-20): + DEL doesn't refund the daily free-draw; the row stays as a quota + tracker until 24h elapse, after which `active_draw_for`'s lazy + cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does). + + Idempotent: re-firing on a row w. already-empty hand is a no-op.""" + draw = active_draw_for(request.user) + if draw is not None: + draw.hand = [] + draw.save(update_fields=["hand"]) return HttpResponse(status=204) +@login_required(login_url="/") +def my_sea_gate(request): + """Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the + button-target placeholder lets the template's GATE VIEW UX wire up + in advance; Sprint 6 will replace this w. the token-deposit flow.""" + return HttpResponse(status=404) + + def _my_sea_deck_data(user, exclude_id=None): """Build the shuffled deck (levity + gravity halves) for the my-sea picker's card-draw mechanic. Card payload shape is whatever diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 7d15f5a..e258f1b 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest): # ── Test 5 ─────────────────────────────────────────────────────────────── - def test_lock_hand_enables_when_sao_hand_is_complete(self): - """LOCK HAND starts disabled; flips to enabled once all 3 SAO - positions are drawn (hand-size = 3 for any three-card spread).""" + def test_action_btn_transitions_to_gate_view_on_hand_complete(self): + """Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO + DRAW (`data-state="auto-draw"`); when the final card lands, JS + transitions it to GATE VIEW (`data-state="gate-view"`, label = + "GATE VIEW").""" picker = self._enter_picker_phase() - lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") - self.assertEqual(lock.get_attribute("disabled"), "true") + action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn") + self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw") + self.assertIn("AUTO", action_btn.text.upper()) self._draw_one(picker, "levity") self._draw_one(picker, "levity") - # Two draws — still disabled. - self.assertEqual(lock.get_attribute("disabled"), "true") self._draw_one(picker, "gravity") - # Third draw completes the SAO hand — LOCK HAND enables. + # Third draw completes the SAO hand — action btn becomes GATE VIEW. self.wait_for( - lambda: self.assertIsNone(lock.get_attribute("disabled")) + lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view") ) + self.assertIn("GATE", action_btn.text.upper()) # ── Test 6 ─────────────────────────────────────────────────────────────── - def test_del_click_resets_hand_and_disables_lock_hand(self): - """DEL fully resets — every filled slot returns to `--empty`, - labels re-render, _filled counter zeros, LOCK HAND disables.""" + def test_del_btn_is_disabled_until_hand_complete(self): + """Iter-4c — DEL btn renders `.btn-disabled` server-side until + the hand is complete (per spec: the 24h free-draw quota is + committed at first-card-draw, can't be refunded by an early + DEL). Once the hand fills, JS removes `.btn-disabled` from DEL.""" picker = self._enter_picker_phase() + delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del") + self.assertIn("btn-disabled", delbtn.get_attribute("class")) + self._draw_one(picker, "levity") + # Mid-draw — still disabled. + self.assertIn("btn-disabled", delbtn.get_attribute("class")) self._draw_one(picker, "levity") self._draw_one(picker, "gravity") - self.assertEqual( - len(picker.find_elements( - By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled" - )), - 2, - ) - delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del") - delbtn.click() + # Hand complete — DEL un-disables (clicking now opens guard portal). self.wait_for( - lambda: self.assertEqual( - len(picker.find_elements( - By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled" - )), - 0, - ) + lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class")) ) - lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") - self.assertEqual(lock.get_attribute("disabled"), "true") # ── Test 7 ─────────────────────────────────────────────────────────────── - def test_lock_hand_click_disables_further_interaction(self): - """After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND - itself all carry the `.btn-disabled` class so the hand can't - be mutated further. Persistence (POST to a server endpoint) - defers to iter 4b — this test pins only the visual lock.""" + def test_hand_completion_locks_picker_state(self): + """Iter-4c — when the final card lands (manual or AUTO DRAW), + the picker gains `.my-sea-picker--locked`; further deck-stack + clicks still SHOW the FLIP btn (so the user can see why no + further drawing is allowed) but the FLIP carries `.btn-disabled` + + cards no longer fire on its click. No discrete LOCK HAND + action; the transition is automatic on hand-completion.""" picker = self._enter_picker_phase() self._draw_one(picker, "levity") self._draw_one(picker, "levity") self._draw_one(picker, "gravity") - lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") - self.wait_for( - lambda: self.assertIsNone(lock.get_attribute("disabled")) - ) - lock.click() - # Picker carries a .my-sea-picker--locked class after LOCK HAND. self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked" ) ) - # Swatches no longer respond — clicking them does nothing. - gravity_stack = picker.find_element( - By.CSS_SELECTOR, ".sea-deck-stack--gravity" - ) - self.assertIn("btn-disabled", gravity_stack.get_attribute("class")) # ── Test 8 ─────────────────────────────────────────────────────────────── def test_first_draw_locks_spread_combobox(self): - """Per the iter-4a follow-up spec lock (2026-05-19): once the - first card lands, the SPREAD combobox carries `.sea-select-- - locked` so mid-draw spread switching is prevented (it would - scramble the position→card mapping). DEL releases the lock. - - Was previously `test_switching_spread_resets_in_progress_hand` - — that test's premise (mid-draw spread switch resets hand) is - obsolete now that switching is blocked outright.""" + """Iter-4c — once the first card lands, the SPREAD combobox + carries `.sea-select--locked` for the rest of the quota window. + The spread is committed at first-card moment (server-side too: + any later POST w. a different spread → 409); no client-side + unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL + `.btn-disabled` pre-completion → no reset pathway.)""" picker = self._enter_picker_phase() self._draw_one(picker, "levity") combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") self.wait_for( lambda: self.assertIn("sea-select--locked", combo.get_attribute("class")) ) - # DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`). - delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del") - delbtn.click() - self.wait_for( - lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class")) - ) # ── Test 4 ─────────────────────────────────────────────────────────────── @@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest): ) self.assertIn("GRAVITY", names) self.assertIn("LEVITY", names) - # LOCK HAND + DEL - lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand") - self.assertIn("LOCK", lock.text.upper()) - self.assertIn("HAND", lock.text.upper()) - self.assertIn("btn-primary", lock.get_attribute("class")) + # Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL. + action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn") + self.assertIn("AUTO", action_btn.text.upper()) + self.assertIn("btn-primary", action_btn.get_attribute("class")) + self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw") delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del") - self.assertIn("DEL", delbtn.text.upper()) + # DEL renders w. `.btn-disabled` pre-completion (the `×` overlay + # is CSS-only; raw text content is still "DEL" in the DOM). + # Assert on class state — `.text` returns the visible glyph + # rendered by the pseudo-element layer. self.assertIn("btn-danger", delbtn.get_attribute("class")) + self.assertIn("btn-disabled", delbtn.get_attribute("class")) # Reversal % caption — default 25 hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint") self.assertIn("25", hint.text) @@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest): # ── Test 5 ─────────────────────────────────────────────────────────────── - def test_del_confirm_clears_saved_draw_and_returns_to_landing(self): - """Clicking the portal's OK (`.guard-yes`) POSTs to the delete - endpoint → server wipes the MySeaDraw row → reload lands on the - FREE DRAW landing again (no saved hand, no Brief banner, FREE - DRAW btn present).""" + def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self): + """Iter-4c semantics: clicking the portal's OK (`.guard-yes`) + POSTs to the delete endpoint → server CLEARS the hand JSON but + preserves the MySeaDraw row (quota tracker stays running for the + 24h window). Reload lands on the table-hex landing — but the + primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT + FREE DRAW, since the quota's spent until the row expires.""" from apps.gameboard.models import MySeaDraw self._save_draw_for_user() self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1) @@ -1184,4 +1168,13 @@ class MySeaLockHandTest(FunctionalTest): By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']" ) ) - self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0) + # Row preserved as quota tracker; hand wiped. + rows = MySeaDraw.objects.filter(user=self.gamer) + self.assertEqual(rows.count(), 1) + self.assertEqual(rows.first().hand, []) + # Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec. + self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn") + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")), + 0, + ) diff --git a/src/static_src/scss/_button-pad.scss b/src/static_src/scss/_button-pad.scss index 8851e85..97d5ebb 100644 --- a/src/static_src/scss/_button-pad.scss +++ b/src/static_src/scss/_button-pad.scss @@ -515,7 +515,7 @@ pointer-events: none; font-size: 1.2rem; padding-bottom: 0.1rem; - color: rgba(var(--secUser), 0.25) !important; + color: transparent !important; // hide native text background-color: rgba(var(--priUser), 1) !important; border-color: rgba(var(--secUser), 0.25) !important; box-shadow: @@ -523,6 +523,28 @@ 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), 0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12) ; + position: relative; + + // Universal × overlay — any `.btn-disabled` button reads as × + // regardless of its native inner text/icons (DEL → ×, FLIP → ×, + // LOCK HAND → ×, etc.). Templates that already render `×` + // explicitly (e.g. don/doff toggle pairs) just have their inner + // × hidden by `visibility: hidden` on children; the pseudo's + // glyph is the only one visible — no double-× regression. User + // spec 2026-05-20. + > * { visibility: hidden; } + + &::before { + content: "\00d7"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: rgba(var(--secUser), 0.5); + font-size: 1.5rem; + font-weight: bold; + } &:hover { text-shadow: diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 80413a3..4e1d215 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -915,7 +915,8 @@ html:has(.sig-backdrop) { // data-polarity lives on the page wrapper (not on .my-sign-stage) so descendant // `.sig-card` (in the grid, sibling to the stage) inherits the rules. .sig-overlay[data-polarity="levity"], -.my-sign-page[data-polarity="levity"] { +.my-sign-page[data-polarity="levity"], +.my-sea-page[data-polarity="levity"] { // Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name // and .fan-card-corner that out-specifc the parent color, so re-target them here. .sig-card { @@ -969,7 +970,8 @@ html:has(.sig-backdrop) { // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) } .sig-overlay[data-polarity="gravity"], -.my-sign-page[data-polarity="gravity"] { +.my-sign-page[data-polarity="gravity"], +.my-sea-page[data-polarity="gravity"] { // Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards .sig-stat-block { background: rgba(var(--secUser), 0.75); diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 0bae981..14fb134 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -276,6 +276,15 @@ body.page-gameboard { background: rgba(var(--duoUser), 1); } +// Landing phase bg — explicit `--priUser` revert per user spec +// (2026-05-20). The hex INTERIOR is `--duoUser` (set on `.table-hex` +// in _room.scss); the aperture AROUND the hex should be the default +// body color. Defensive override so any bf-cache / stale-CSS state +// can't leak the picker-phase green bg onto a landing render. +.my-sea-page[data-phase="landing"] { + background: rgba(var(--priUser), 1); +} + .my-sea-picker { flex: 1; min-height: 0; diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 0a94db0..8e5cdef 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -5,7 +5,9 @@ {% block header_text %}GameSea{% endblock header_text %} {% block content %} -
+
{% if not user_has_sig %} {# Sprint 4b sign-gate. The draw UX is gated behind a saved #} {# significator — render a Look!-formatted Brief-style line w. #} @@ -24,15 +26,20 @@
{% else %} - {% if not active_draw %} + {% if not show_picker %} {# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #} {# the room shell (.room-shell > .room-table > … > .table-hex) #} {# w. 6 chair seats labeled 1C-6C as placeholders for the #} {# friend-invite feature per the My Sea roadmap architectural #} - {# anchor "Six chairs retained even in solo". DRAW SEA btn #} - {# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #} - {# an active draw exists (iter 4b) — the picker phase is the #} - {# landing state once the user has spent their free quota. #} + {# anchor "Six chairs retained even in solo". #} + {# #} + {# Iter 4c — landing primary btn is: #} + {# • FREE DRAW (`#id_draw_sea_btn`) when the user has no #} + {# active quota row (fresh user OR >24h since last draw); #} + {# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #} + {# quota row exists but the hand is empty (post-DEL). The #} + {# daily free draw is spent; further draws require a token #} + {# deposit via the Sprint-6 gatekeeper (currently 404). #}
@@ -40,13 +47,14 @@
- {# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #} - {# Future sprint will conditionally swap this for a DRAW SEA #} - {# .btn-primary that calls the gatekeeper partial once the #} - {# free daily has been used; until then the btn renders FREE #} - {# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #} - {# entry point) so the swap is label-only when iter 6+ lands. #} - + {% if quota_spent %} + + {% else %} + + {% endif %}
@@ -86,7 +94,7 @@ {# exist in one DOM). FLIP click delegates to SeaDeal. #} {# openStage(), which fills the slot AND opens the portaled #} {# stage modal w. SPIN / FYI controls. #} -