My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + _select_my_sea_token / debit_my_sea_token helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD

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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 02:29:08 -04:00
parent 7b7e80520a
commit 3fc5491372
8 changed files with 986 additions and 14 deletions

View File

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

View File

@@ -55,6 +55,15 @@ class MySeaDraw(models.Model):
significator_id = models.IntegerField() significator_id = models.IntegerField()
significator_reversed = models.BooleanField(default=False) significator_reversed = models.BooleanField(default=False)
created_at = models.DateTimeField(default=timezone.now) 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: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
@@ -104,6 +113,70 @@ class MySeaDraw(models.Model):
return deleted 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): def active_draw_for(user):
"""Return the user's most-recent draw within the quota window, or """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" None. Single source of truth for "does the user have an active draw"

View File

@@ -1,7 +1,9 @@
import lxml.html import lxml.html
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant, Room, TableSeat from apps.epic.models import DeckVariant, Room, TableSeat
@@ -1368,21 +1370,325 @@ class MySeaViewWithPartialHandTest(TestCase):
self.assertNotIn("my-sea-picker--locked", m.group(1)) self.assertNotIn("my-sea-picker--locked", m.group(1))
class MySeaGateStubViewTest(TestCase): class MySeaGateViewTest(TestCase):
"""Sprint 5 iter 4cplaceholder for the Sprint 6 gatekeeper. Returns """Sprint 6 iter 6a`my_sea_gate` renders the solo gatekeeper UI.
a 404 so the template-side GATE VIEW button URL resolves but the Replaces the iter-4c 404 stub. Branches on whether a deposit is
actual gatekeeper UX rides Sprint 6.""" already reserved on the user's MySeaDraw row."""
def setUp(self): 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.user = User.objects.create(email="gate@test.io")
self.client.force_login(self.user) self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
def test_gate_view_returns_404(self): self.user.significator = self.target
response = self.client.get(reverse("my_sea_gate")) self.user.save(update_fields=["significator"])
self.assertEqual(response.status_code, 404) # 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): def test_gate_view_requires_login(self):
self.client.logout() self.client.logout()
response = self.client.get(reverse("my_sea_gate")) response = self.client.get(reverse("my_sea_gate"))
# @login_required redirects before the 404 path runs.
self.assertEqual(response.status_code, 302) 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())

View File

@@ -17,5 +17,8 @@ urlpatterns = [
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'), 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/delete', views.my_sea_delete, name='my_sea_delete'),
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'), 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'),
] ]

View File

@@ -7,7 +7,10 @@ from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle 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): def _annotate_deck_in_use(decks, user):
@@ -361,10 +364,112 @@ def my_sea_delete(request):
@login_required(login_url="/") @login_required(login_url="/")
def my_sea_gate(request): def my_sea_gate(request):
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the """Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's
button-target placeholder lets the template's GATE VIEW UX wire up `_gatekeeper.html` structure (coin-slot rails + refund affordance)
in advance; Sprint 6 will replace this w. the token-deposit flow.""" adapted for 1-user, 1-token-per-redraw semantics. The user spends a
return HttpResponse(status=404) 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): def _my_sea_deck_data(user, exclude_id=None):

View File

@@ -1178,3 +1178,380 @@ class MySeaLockHandTest(FunctionalTest):
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")), len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
0, 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())

View File

@@ -285,6 +285,19 @@ body.page-gameboard {
background: rgba(var(--priUser), 1); 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 { .my-sea-picker {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@@ -0,0 +1,72 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Game Sea Gate{% endblock title_text %}
{% block header_text %}<span>Game</span><span>Gate</span>{% 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. #}
<div class="my-sea-page my-sea-gate-page"
data-phase="gate"
data-polarity="{% if significator_reversed %}gravity{% else %}levity{% endif %}">
<div id="id_gate_wrapper" class="my-sea-gate-wrapper">
<div class="gate-backdrop"></div>
<div class="gate-overlay my-sea-gate-overlay">
<div class="gate-modal my-sea-gate-modal" role="dialog" aria-label="My Sea Gatekeeper">
{# Coin-slot rails — INSERT TOKEN TO PLAY pre-deposit; #}
{# PUSH TO RETURN (refund btn) post-deposit. Mirrors #}
{# `_gatekeeper.html`'s `.token-slot` shape so the SCSS #}
{# from `_room.scss` carries over (rail glow on active #}
{# state, claimed state on deposit, etc.). #}
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
{% if not deposit_reserved %}
<form method="POST" action="{% url 'my_sea_insert_token' %}" style="display:contents">
{% csrf_token %}
<button type="submit" class="token-rails" aria-label="Insert token to play">
<span class="rail"></span>
<span class="rail"></span>
</button>
</form>
{% else %}
<div class="token-rails">
<span class="rail"></span>
<span class="rail"></span>
</div>
{% endif %}
<div class="token-panel">
<div class="token-denomination">1</div>
<span class="token-insert-label">INSERT TOKEN TO PLAY</span>
<span class="token-return-label">PUSH TO RETURN</span>
</div>
{% if deposit_reserved %}
<form method="POST" action="{% url 'my_sea_refund_token' %}" style="display:contents">
{% csrf_token %}
<button type="submit" class="token-return-btn" aria-label="Push to return"></button>
</form>
{% endif %}
</div>
{# PAID DRAW — commits the deposit + redirects to my-sea. #}
{# Mirrors the room's PICK ROLES btn shape (`.btn-primary` #}
{# alongside the coin-slot in `.gate-top-row`). #}
{% if deposit_reserved %}
<form method="POST" action="{% url 'my_sea_paid_draw' %}" class="my-sea-paid-draw-form">
{% csrf_token %}
<button type="submit"
id="id_my_sea_paid_draw_btn"
class="btn btn-primary">PAID<br>DRAW</button>
</form>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock content %}