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

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