From 3fc5491372e3e274e5e512cbe3d6884d2a3cadf5 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 20 May 2026 02:29:08 -0400 Subject: [PATCH] =?UTF-8?q?My=20Sea=20iter=206a:=20gatekeeper=20page=20+?= =?UTF-8?q?=20INSERT/REFUND/PAID=20DRAW=20endpoints=20+=20MySeaDraw=20depo?= =?UTF-8?q?sit=20fields=20+=20`=5Fselect=5Fmy=5Fsea=5Ftoken`=20/=20`debit?= =?UTF-8?q?=5Fmy=5Fsea=5Ftoken`=20helpers=20(CARTE=20blocked,=20COIN=2024h?= =?UTF-8?q?=20cooldown=20not=207-day)=20+=20Sprint=206=20FT=20skeleton=20?= =?UTF-8?q?=E2=80=94=20Sprint=205=20iter=206a=20of=20My=20Sea=20roadmap=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 --- ..._myseadraw_deposit_reserved_at_and_more.py | 23 ++ src/apps/gameboard/models.py | 73 ++++ .../gameboard/tests/integrated/test_views.py | 324 ++++++++++++++- src/apps/gameboard/urls.py | 3 + src/apps/gameboard/views.py | 115 +++++- src/functional_tests/test_game_my_sea.py | 377 ++++++++++++++++++ src/static_src/scss/_gameboard.scss | 13 + src/templates/apps/gameboard/my_sea_gate.html | 72 ++++ 8 files changed, 986 insertions(+), 14 deletions(-) create mode 100644 src/apps/gameboard/migrations/0002_myseadraw_deposit_reserved_at_and_more.py create mode 100644 src/templates/apps/gameboard/my_sea_gate.html diff --git a/src/apps/gameboard/migrations/0002_myseadraw_deposit_reserved_at_and_more.py b/src/apps/gameboard/migrations/0002_myseadraw_deposit_reserved_at_and_more.py new file mode 100644 index 0000000..0bebcf8 --- /dev/null +++ b/src/apps/gameboard/migrations/0002_myseadraw_deposit_reserved_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2026-05-20 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gameboard', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='myseadraw', + name='deposit_reserved_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='myseadraw', + name='deposit_token_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index f24606f..d613c4a 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -55,6 +55,15 @@ class MySeaDraw(models.Model): significator_id = models.IntegerField() significator_reversed = models.BooleanField(default=False) created_at = models.DateTimeField(default=timezone.now) + # Sprint 5 iter 6a — gatekeeper deposit lifecycle. A token can be + # RESERVED on the row (refundable until the user clicks PAID DRAW) + # via the my-sea gatekeeper at /gameboard/my-sea/gate/. PAID DRAW + # commits the deposit + resets the row (`hand=[]`, `created_at=now`, + # both deposit_* fields back to None), starting a fresh 24h quota + # cycle paid for by the deposited token. See `_select_my_sea_token` + # + `debit_my_sea_token` for the priority chain + per-type rules. + deposit_token_id = models.IntegerField(null=True, blank=True) + deposit_reserved_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] @@ -104,6 +113,70 @@ class MySeaDraw(models.Model): return deleted +def _select_my_sea_token(user): + """Token-picker for the my-sea gatekeeper. Mirrors `apps.epic.models. + select_token` priority chain (PASS → COIN → FREE → TITHE) w. two + iter-6a tweaks per user spec 2026-05-20: + + - **CARTE excluded.** The door-spell trinket isn't a valid token + for paying my-sea draws. + - **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a + COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h` (not + the room's 7-day window). A cooldown'd COIN is unavailable to + this picker; standard `select_token` ignores `next_ready_at` so + room logic stays untouched (intentional: my-sea is the only path + that respects this cooldown). + """ + from django.db.models import Q + from apps.lyric.models import Token + now = timezone.now() + if user.is_staff: + pass_token = user.tokens.filter(token_type=Token.PASS).first() + if pass_token: + return pass_token + coin = user.tokens.filter( + token_type=Token.COIN, current_room__isnull=True, + ).filter( + Q(next_ready_at__isnull=True) | Q(next_ready_at__lte=now), + ).first() + if coin: + return coin + free = user.tokens.filter( + token_type=Token.FREE, expires_at__gt=now, + ).order_by("expires_at").first() + if free: + return free + return user.tokens.filter(token_type=Token.TITHE).first() + + +def debit_my_sea_token(user, token): + """Commit a my-sea-deposited token. Adapted from `apps.epic.models. + debit_token` for solo my-sea per user spec 2026-05-20: + + - **CARTE → ValueError.** Defensive guard; caller should validate + upstream via `_select_my_sea_token`. + - **COIN.** No `current_room` (there is no room context); instead, + set `next_ready_at = now + 24h` so the next-pickup gate kicks in. + Unequip from the kit if equipped (parity w. room's COIN behavior). + - **PASS.** No consumption — auto-admit. Stays equipped. + - **FREE / TITHE.** Consumed (deleted). + """ + from datetime import timedelta + from apps.lyric.models import Token + if token.token_type == Token.CARTE: + raise ValueError("CARTE cannot pay for a my-sea draw") + if token.token_type == Token.COIN: + token.next_ready_at = timezone.now() + timedelta(hours=24) + token.save(update_fields=["next_ready_at"]) + if user.equipped_trinket_id == token.pk: + user.equipped_trinket = None + user.save(update_fields=["equipped_trinket"]) + elif token.token_type == Token.PASS: + pass + else: + token.delete() + + def active_draw_for(user): """Return the user's most-recent draw within the quota window, or None. Single source of truth for "does the user have an active draw" diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index f2bf267..63a28b1 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1,7 +1,9 @@ import lxml.html +from datetime import timedelta from django.test import TestCase from django.urls import reverse +from django.utils import timezone from apps.applets.models import Applet, UserApplet from apps.epic.models import DeckVariant, Room, TableSeat @@ -1368,21 +1370,325 @@ class MySeaViewWithPartialHandTest(TestCase): self.assertNotIn("my-sea-picker--locked", m.group(1)) -class MySeaGateStubViewTest(TestCase): - """Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns - a 404 so the template-side GATE VIEW button URL resolves but the - actual gatekeeper UX rides Sprint 6.""" +class MySeaGateViewTest(TestCase): + """Sprint 6 iter 6a — `my_sea_gate` renders the solo gatekeeper UI. + Replaces the iter-4c 404 stub. Branches on whether a deposit is + already reserved on the user's MySeaDraw row.""" def setUp(self): + from apps.epic.models import personal_sig_cards + from datetime import timedelta + from apps.lyric.models import Token self.user = User.objects.create(email="gate@test.io") self.client.force_login(self.user) - - def test_gate_view_returns_404(self): - response = self.client.get(reverse("my_sea_gate")) - self.assertEqual(response.status_code, 404) + self.target = personal_sig_cards(self.user)[0] + self.user.significator = self.target + self.user.save(update_fields=["significator"]) + # Seed a FREE token so deposit attempts have something to pick. + Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) def test_gate_view_requires_login(self): self.client.logout() response = self.client.get(reverse("my_sea_gate")) - # @login_required redirects before the 404 path runs. self.assertEqual(response.status_code, 302) + + def test_gate_view_renders_200(self): + response = self.client.get(reverse("my_sea_gate")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "apps/gameboard/my_sea_gate.html") + + def test_gate_view_shows_insert_token_form_when_no_deposit(self): + 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_gate")) + self.assertContains(response, reverse("my_sea_insert_token")) + self.assertNotContains(response, "id_my_sea_paid_draw_btn") + + def test_gate_view_shows_paid_draw_btn_when_deposit_reserved(self): + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + free_tok = Token.objects.filter(user=self.user, token_type=Token.FREE).first() + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + deposit_token_id=free_tok.pk, + deposit_reserved_at=timezone.now(), + ) + response = self.client.get(reverse("my_sea_gate")) + self.assertContains(response, "id_my_sea_paid_draw_btn") + self.assertContains(response, reverse("my_sea_refund_token")) + + +class MySeaInsertTokenViewTest(TestCase): + """Sprint 6 iter 6a — POST `/gameboard/my-sea/insert` reserves the + user's next-priority token on their MySeaDraw row (creates the row + if missing). Idempotent w.r.t. an already-reserved deposit.""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + self.user = User.objects.create(email="insert@test.io") + # Wipe auto-tokens from User post_save signal (COIN + FREE). + self.user.tokens.all().delete() + 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"]) + self.free_tok = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + self.url = reverse("my_sea_insert_token") + + def test_insert_get_returns_405(self): + self.assertEqual(self.client.get(self.url).status_code, 405) + + def test_insert_creates_row_and_reserves_token(self): + from apps.gameboard.models import MySeaDraw + self.client.post(self.url) + draw = MySeaDraw.objects.get(user=self.user) + self.assertEqual(draw.deposit_token_id, self.free_tok.pk) + self.assertIsNotNone(draw.deposit_reserved_at) + + def test_insert_uses_existing_row_when_one_exists(self): + from apps.gameboard.models import MySeaDraw + draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + ) + self.client.post(self.url) + draw.refresh_from_db() + self.assertEqual(draw.deposit_token_id, self.free_tok.pk) + self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1) + + def test_insert_idempotent_when_deposit_already_reserved(self): + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + other_tok = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + deposit_token_id=other_tok.pk, + deposit_reserved_at=timezone.now(), + ) + self.client.post(self.url) + draw.refresh_from_db() + # Still pointed at the original token; no double-reserve. + self.assertEqual(draw.deposit_token_id, other_tok.pk) + + +class MySeaRefundTokenViewTest(TestCase): + """Sprint 6 iter 6a — POST `/gameboard/my-sea/refund` clears the + deposit fields. Token wasn't actually consumed at INSERT (refund- + aware design), so no inventory side effects.""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + from datetime import timedelta + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + self.user = User.objects.create(email="refund@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"]) + self.tok = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + self.draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + deposit_token_id=self.tok.pk, + deposit_reserved_at=timezone.now(), + ) + self.url = reverse("my_sea_refund_token") + + def test_refund_clears_deposit_fields(self): + self.client.post(self.url) + self.draw.refresh_from_db() + self.assertIsNone(self.draw.deposit_token_id) + self.assertIsNone(self.draw.deposit_reserved_at) + + def test_refund_does_not_consume_token(self): + from apps.lyric.models import Token + self.client.post(self.url) + self.assertTrue(Token.objects.filter(pk=self.tok.pk).exists()) + + def test_refund_idempotent_when_no_deposit(self): + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.all().delete() + response = self.client.post(self.url) + self.assertIn(response.status_code, (200, 204, 302)) + + +class MySeaPaidDrawViewTest(TestCase): + """Sprint 6 iter 6a — POST `/gameboard/my-sea/paid-draw` commits the + deposited token + resets the row for a fresh 24h quota cycle. Per- + token-type debit rules apply (FREE/TITHE consumed, COIN cooldown, + PASS no-op, CARTE not reachable via `_select_my_sea_token`).""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + from datetime import timedelta + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + self.user = User.objects.create(email="paid@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"]) + self.free_tok = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + self.draw = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + deposit_token_id=self.free_tok.pk, + deposit_reserved_at=timezone.now(), + ) + self.url = reverse("my_sea_paid_draw") + + def test_paid_draw_consumes_free_token(self): + from apps.lyric.models import Token + 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 + 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)) + + 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_with_coin_sets_24h_cooldown_and_unequips(self): + from datetime import timedelta + from apps.lyric.models import Token + coin = Token.objects.create(user=self.user, token_type=Token.COIN) + self.user.equipped_trinket = coin + self.user.save(update_fields=["equipped_trinket"]) + self.draw.deposit_token_id = coin.pk + self.draw.save(update_fields=["deposit_token_id"]) + before = timezone.now() + self.client.post(self.url) + coin.refresh_from_db() + self.assertTrue(coin.next_ready_at >= before + timedelta(hours=23, minutes=58)) + self.assertTrue(coin.next_ready_at <= before + timedelta(hours=24, minutes=2)) + self.user.refresh_from_db() + self.assertIsNone(self.user.equipped_trinket_id) + + def test_paid_draw_with_pass_does_not_consume(self): + from apps.lyric.models import Token + pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) + self.draw.deposit_token_id = pass_tok.pk + self.draw.save(update_fields=["deposit_token_id"]) + self.client.post(self.url) + self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists()) + + def test_paid_draw_no_deposit_redirects_to_my_sea(self): + self.draw.deposit_token_id = None + self.draw.save(update_fields=["deposit_token_id"]) + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + + +class SelectMySeaTokenTest(TestCase): + """Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE + excluded + COIN cooldown-respecting.""" + + def setUp(self): + self.user = User.objects.create(email="selecttok@test.io") + # New-user post_save signal auto-creates COIN + FREE tokens + # (`apps.lyric.models`). Wipe them so each test only sees the + # tokens it explicitly seeds. + self.user.tokens.all().delete() + + def test_carte_is_excluded(self): + from apps.gameboard.models import _select_my_sea_token + Token.objects.create(user=self.user, token_type=Token.CARTE) + self.assertIsNone(_select_my_sea_token(self.user)) + + def test_cooldown_coin_is_excluded(self): + from apps.gameboard.models import _select_my_sea_token + Token.objects.create( + user=self.user, token_type=Token.COIN, + next_ready_at=timezone.now() + timedelta(hours=12), + ) + self.assertIsNone(_select_my_sea_token(self.user)) + + def test_pass_wins_priority_for_staff(self): + from apps.lyric.models import Token + from apps.gameboard.models import _select_my_sea_token + self.user.is_staff = True + self.user.save(update_fields=["is_staff"]) + pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) + Token.objects.create(user=self.user, token_type=Token.COIN) + self.assertEqual(_select_my_sea_token(self.user), pass_tok) + + +class DebitMySeaTokenTest(TestCase): + """Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics.""" + + def setUp(self): + self.user = User.objects.create(email="debittok@test.io") + + def test_carte_raises_value_error(self): + from apps.lyric.models import Token + from apps.gameboard.models import debit_my_sea_token + carte = Token.objects.create(user=self.user, token_type=Token.CARTE) + with self.assertRaises(ValueError): + debit_my_sea_token(self.user, carte) + + def test_free_token_is_consumed(self): + from datetime import timedelta + from apps.lyric.models import Token + from apps.gameboard.models import debit_my_sea_token + free = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + debit_my_sea_token(self.user, free) + self.assertFalse(Token.objects.filter(pk=free.pk).exists()) + + def test_tithe_token_is_consumed(self): + from apps.lyric.models import Token + from apps.gameboard.models import debit_my_sea_token + tithe = Token.objects.create(user=self.user, token_type=Token.TITHE) + debit_my_sea_token(self.user, tithe) + self.assertFalse(Token.objects.filter(pk=tithe.pk).exists()) + + def test_pass_token_is_not_consumed(self): + from apps.lyric.models import Token + from apps.gameboard.models import debit_my_sea_token + pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) + debit_my_sea_token(self.user, pass_tok) + self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists()) diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index d16b9f7..bbd583c 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -17,5 +17,8 @@ urlpatterns = [ path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'), path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'), path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'), + 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'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index bc68758..d0e40e8 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -7,7 +7,10 @@ from django.utils import timezone from django.views.decorators.http import require_POST from apps.applets.utils import applet_context, apply_applet_toggle -from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for +from .models import ( + HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, + _select_my_sea_token, debit_my_sea_token, +) def _annotate_deck_in_use(decks, user): @@ -361,10 +364,112 @@ def my_sea_delete(request): @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) + """Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's + `_gatekeeper.html` structure (coin-slot rails + refund affordance) + adapted for 1-user, 1-token-per-redraw semantics. The user spends a + token (PASS/COIN/FREE/TITHE — CARTE excluded) to acquire a fresh + 24h quota cycle after their daily free draw is spent. + + Branches on `MySeaDraw.deposit_token_id`: + - None (no deposit yet) → INSERT TOKEN TO PLAY rails are active. + - non-None (deposit reserved) → refund affordance + PAID DRAW btn. + """ + active_draw = active_draw_for(request.user) + sig_card, sig_reversed = _resolve_sig(request.user, active_draw) + deposit_reserved = ( + active_draw is not None and active_draw.deposit_token_id is not None + ) + hand_non_empty = active_draw is not None and bool(active_draw.hand) + return render(request, "apps/gameboard/my_sea_gate.html", { + "user_has_sig": sig_card is not None, + "significator": sig_card, + "significator_reversed": sig_reversed, + "active_draw": active_draw, + "deposit_reserved": deposit_reserved, + "hand_non_empty": hand_non_empty, + "page_class": "page-gameboard page-my-sea page-my-sea-gate", + }) + + +@login_required(login_url="/") +@require_POST +def my_sea_insert_token(request): + """Reserve the user's next-priority token on their MySeaDraw row. + Idempotent w.r.t. an already-reserved deposit — re-posting is a no-op + rather than double-debit. Creates the row if none exists (so a fresh + user can hit the gatekeeper without first using their free draw).""" + active_draw = active_draw_for(request.user) + if active_draw is None: + # No active row yet — create a quota tracker row w. empty hand + # so the deposit has something to attach to. This also commits + # the user's free-draw quota for the day (since `active_draw_ + # for` will now return this row). + sig_id = request.user.significator_id + if sig_id is None: + return redirect("my_sea_gate") + active_draw = MySeaDraw.objects.create( + user=request.user, + spread="situation-action-outcome", + hand=[], + significator_id=sig_id, + significator_reversed=request.user.significator_reversed, + ) + if active_draw.deposit_token_id is not None: + return redirect("my_sea_gate") + token = _select_my_sea_token(request.user) + if token is None: + return redirect("my_sea_gate") + active_draw.deposit_token_id = token.pk + active_draw.deposit_reserved_at = timezone.now() + active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) + return redirect("my_sea_gate") + + +@login_required(login_url="/") +@require_POST +def my_sea_refund_token(request): + """Clear the user's deposit reservation. Token wasn't actually + debited at INSERT (refund-aware design), so this is purely a row + update — no side effects on the user's inventory.""" + active_draw = active_draw_for(request.user) + if active_draw is not None and active_draw.deposit_token_id is not None: + active_draw.deposit_token_id = None + active_draw.deposit_reserved_at = None + active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) + return redirect("my_sea_gate") + + +@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.""" + 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: + return redirect("my_sea") + token = Token.objects.filter( + pk=active_draw.deposit_token_id, user=request.user, + ).first() + if token is None: + # Token vanished between reserve + commit (unlikely w. solo + # flow but defensive). Clear deposit + bounce to my-sea. + active_draw.deposit_token_id = None + active_draw.deposit_reserved_at = None + 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") 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 e258f1b..53651f2 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -1178,3 +1178,380 @@ class MySeaLockHandTest(FunctionalTest): len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")), 0, ) + + +# ── Sprint 6 — my-sea gatekeeper (token-deposit-to-redraw within 24h) ───────── +# FT skeleton written TDD-first per user spec 2026-05-20. See +# [[sprint-my-sea-iter-6-plan]] for the full spec; iters 6a/6b/6c break +# the work into three commits but the FTs describe the user-facing +# behavior end-to-end so the impl can converge against them. + + +class MySeaGatekeeperPageTest(FunctionalTest): + """Sprint 6 iter 6a — `/gameboard/my-sea/gate/` renders the solo + gatekeeper UI. Coin-slot rails (INSERT TOKEN TO PLAY) + 6-chair hex + (seat 1 always reserved for owner, others banned). One-token-per- + draw, refundable until PAID DRAW commits.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "gate@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + # Seed a FREE token so the gatekeeper has something to deposit. + from datetime import timedelta + from django.utils import timezone as dj_tz + from apps.lyric.models import Token + Token.objects.create( + user=self.gamer, token_type=Token.FREE, + expires_at=dj_tz.now() + timedelta(days=30), + ) + + def _save_empty_hand_draw(self): + """Quota-spent state: an active MySeaDraw row w. empty hand + (post-DEL or post-completion-DEL). This is the canonical state + where the gatekeeper is meaningful.""" + from apps.gameboard.models import MySeaDraw + return MySeaDraw.objects.create( + user=self.gamer, spread="situation-action-outcome", + significator_id=self.gamer.significator_id, hand=[], + ) + + def test_gatekeeper_page_renders_token_rails_in_empty_state(self): + """No deposit yet → coin-slot shows INSERT TOKEN TO PLAY + the + rails are active (click-target). No refund btn yet.""" + self._save_empty_hand_draw() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + rails_form = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "form[action$='/my-sea/insert']" + ) + ) + rails_form.find_element(By.CSS_SELECTOR, "button.token-rails") + # No refund btn or PAID DRAW yet. + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "form[action$='/my-sea/refund']" + )), + 0, + ) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + )), + 0, + ) + + def test_gatekeeper_renders_six_chair_seats_with_seat1_seated(self): + """Hex w. 6 chair seats; seat 1 is the owner's (always `.seated` + when quota is committed); seats 2-6 carry `.fa-ban` (placeholders + for the future friend-invite feature).""" + self._save_empty_hand_draw() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + self.wait_for( + lambda: self._assert_seats(6) + ) + seat1 = self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='1']" + ) + self.assertIn("seated", seat1.get_attribute("class")) + seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check") + + def _assert_seats(self, count): + seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat") + if len(seats) != count: + raise AssertionError(f"expected {count} seats, got {len(seats)}") + return seats + + def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self): + """Click INSERT TOKEN → server reserves the user's next-priority + token on the MySeaDraw row; gatekeeper re-renders w. refund btn + + `#id_my_sea_paid_draw_btn` visible.""" + self._save_empty_hand_draw() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + rails = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/insert'] button.token-rails", + ) + ) + rails.click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + ) + ) + # Refund affordance is now present. + self.browser.find_element( + By.CSS_SELECTOR, "form[action$='/my-sea/refund']" + ) + # Server-side: MySeaDraw row has deposit_token_id set. + from apps.gameboard.models import MySeaDraw + draw = MySeaDraw.objects.get(user=self.gamer) + self.assertIsNotNone(draw.deposit_token_id) + + def test_refund_clears_deposit_and_returns_to_empty_state(self): + from apps.gameboard.models import MySeaDraw + self._save_empty_hand_draw() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/insert'] button.token-rails", + ) + ).click() + refund = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/refund'] button", + ) + ) + refund.click() + # After refund, INSERT TOKEN form is back; PAID DRAW gone. + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/insert']", + ) + ) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + )), + 0, + ) + draw = MySeaDraw.objects.get(user=self.gamer) + self.assertIsNone(draw.deposit_token_id) + + def test_paid_draw_commits_token_and_redirects_to_picker(self): + """PAID DRAW commits the deposited token (FREE token gets + consumed → user's token count drops by 1); server resets the + MySeaDraw row (hand=[], created_at=now, deposit cleared); user + lands back on /gameboard/my-sea/ ready to draw a fresh hand.""" + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + self._save_empty_hand_draw() + free_count_before = Token.objects.filter( + user=self.gamer, token_type=Token.FREE, + ).count() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/insert'] button.token-rails", + ) + ).click() + paid_draw = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + ) + ) + paid_draw.click() + # Redirect lands on /gameboard/my-sea/ (landing or picker). + self.wait_for( + lambda: self.assertIn("/gameboard/my-sea/", self.browser.current_url) + ) + # FREE token consumed. + self.assertEqual( + Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), + free_count_before - 1, + ) + # MySeaDraw row: hand reset to empty, deposit cleared, fresh quota. + draw = MySeaDraw.objects.get(user=self.gamer) + self.assertEqual(draw.hand, []) + self.assertIsNone(draw.deposit_token_id) + + +class MySeaLandingPaidDrawTest(FunctionalTest): + """Sprint 6 iter 6b — landing center-btn state machine extended w. + PAID DRAW. After depositing in the gatekeeper, refresh / navigate + back to /gameboard/my-sea/ → landing renders PAID DRAW (not GATE + VIEW) at the hex center.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "landpaid@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + + def test_landing_shows_paid_draw_btn_when_deposit_reserved(self): + from datetime import timedelta + from django.utils import timezone as dj_tz + from apps.gameboard.models import MySeaDraw + from apps.lyric.models import Token + free_tok = Token.objects.create( + user=self.gamer, token_type=Token.FREE, + expires_at=dj_tz.now() + timedelta(days=30), + ) + MySeaDraw.objects.create( + user=self.gamer, spread="situation-action-outcome", + significator_id=self.gamer.significator_id, hand=[], + deposit_token_id=free_tok.pk, + deposit_reserved_at=dj_tz.now(), + ) + 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, "#id_my_sea_paid_draw_btn" + ) + ) + # FREE DRAW + GATE VIEW are NOT shown when a deposit is reserved. + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")), + 0, + ) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")), + 0, + ) + + +class MySeaNavbarGateViewTest(FunctionalTest): + """Sprint 6 iter 6b — navbar CONT GAME swaps to GATE VIEW whenever + the user is on `body.page-my-sea`. Always reachable, regardless of + quota state — clicking takes the user to /gameboard/my-sea/gate/.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "navgate@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + + def test_navbar_renders_gate_view_btn_on_my_sea_page(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + nav = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".navbar") + ) + # GATE VIEW btn present, CONT GAME btn not. + nav.find_element(By.CSS_SELECTOR, "#id_navbar_gate_view_btn") + self.assertEqual( + len(nav.find_elements(By.CSS_SELECTOR, "#id_navbar_cont_game_btn")), + 0, + ) + + +class MySeaSeatOnePersistenceTest(FunctionalTest): + """Sprint 6 iter 6b — seat 1 (the owner's reserved chair) renders + `.seated` whenever the user's hand is non-empty (mid-draw or + complete). DEL empties the hand → seat 1 reverts to `.fa-ban`.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "seat1@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + + def test_seat_1_banned_for_fresh_user_no_quota(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + seat1 = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='1']" + ) + ) + self.assertNotIn("seated", seat1.get_attribute("class")) + seat1.find_element(By.CSS_SELECTOR, ".fa-ban") + + def test_seat_1_banned_when_active_draw_has_empty_hand(self): + """DEL leaves the row but wipes the hand; seat 1 reverts to + banned (per user spec 2026-05-20: seat 1 tied to hand non-empty, + NOT to active_draw existence).""" + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.gamer, spread="situation-action-outcome", + significator_id=self.gamer.significator_id, hand=[], + ) + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + seat1 = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='1']" + ) + ) + self.assertNotIn("seated", seat1.get_attribute("class")) + + +class MySeaBudBtnStubTest(FunctionalTest): + """Sprint 6 iter 6c — bud-btn invite panel rendered on the + gatekeeper. Panel opens + autocomplete works (reuses billboard: + search_buds), but the OK btn is a no-op stub — POSTs return a + 'Multiplayer my-sea coming soon' Brief banner. Async invite is + deferred to a future sprint.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "bud@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + # Seed a quota row so the gatekeeper has 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_bud_btn_panel_opens_on_gatekeeper(self): + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + bud_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_bud_btn") + ) + bud_btn.click() + # Panel opens — html.bud-open class is added. + self.wait_for( + lambda: self.assertIn( + "bud-open", + self.browser.find_element(By.TAG_NAME, "html").get_attribute("class"), + ) + ) + self.browser.find_element(By.ID, "id_recipient") + + def test_bud_btn_ok_renders_coming_soon_brief(self): + from apps.lyric.models import User as _U + # Seed a friend so the OK click has a recipient to "invite". + _U.objects.create(email="friend@test.io") + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + bud_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_bud_btn") + ) + bud_btn.click() + recipient = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_recipient") + ) + # Wait for slide-out animation to settle (per + # [[feedback-css-transition-selenium-click-race]]). + self.wait_for( + lambda: self.assertGreater( + self.browser.execute_script( + "return document.getElementById('id_bud_panel').getBoundingClientRect().width;" + ), + 100, + ) + ) + recipient.send_keys("friend@test.io") + self.browser.find_element( + By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" + ).click() + # Brief banner appears w. coming-soon copy. + brief = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner") + ) + self.assertIn("coming soon", brief.text.lower()) diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 14fb134..389cb25 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -285,6 +285,19 @@ body.page-gameboard { background: rgba(var(--priUser), 1); } +// Sprint 6 iter 6a — gatekeeper page bg + modal chrome. The page bg +// is uniform `--duoUser` (matches the hex interior on landing / +// picker so the visual transitions read as a continuous surface); +// the `.gate-overlay`/`.gate-modal` rules in `_room.scss` already +// give us the darkened Gaussian-glass modal centered over it. No hex +// or chair-seats on this page — the gatekeeper is a transient in- +// flight UI per user spec 2026-05-20. +.my-sea-page[data-phase="gate"] { + background: rgba(var(--duoUser), 1); + flex: 1; + min-height: 0; +} + .my-sea-picker { flex: 1; min-height: 0; diff --git a/src/templates/apps/gameboard/my_sea_gate.html b/src/templates/apps/gameboard/my_sea_gate.html new file mode 100644 index 0000000..fdf055c --- /dev/null +++ b/src/templates/apps/gameboard/my_sea_gate.html @@ -0,0 +1,72 @@ +{% extends "core/base.html" %} +{% load static %} + +{% block title_text %}Game Sea Gate{% endblock title_text %} +{% block header_text %}GameGate{% endblock header_text %} + +{% block content %} +{# Sprint 6 iter 6a — solo my-sea gatekeeper. Token-deposit-to-redraw #} +{# within the 24h window after the user's daily free draw is spent. #} +{# Layout: `--duoUser` page bg + darkened Gaussian-glass modal centered #} +{# over it (mirrors the room gatekeeper's `.gate-overlay` + `.gate- #} +{# modal` chrome). No hex / chair-seats — the gatekeeper is a transient #} +{# in-flight UI; seats live on the my-sea picker page itself. #} +
+ +
+
+
+ +
+
+
+{% endblock content %}