My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD
Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.
## Server
`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").
`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.
`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.
`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.
`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn
## Template + UX
- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
- `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
- `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
- JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.
## Other polish bundled
- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `×` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).
## Test coverage
- ITs (1100 IT/UT green in 57s):
- `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
- `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
- `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
- `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
- `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
- `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
- `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
- Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
- Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
- Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
- Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
- Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
- Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.
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:
@@ -7,7 +7,7 @@ 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 MySeaDraw, active_draw_for
|
||||
from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
|
||||
|
||||
|
||||
def _annotate_deck_in_use(decks, user):
|
||||
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
|
||||
def my_sea(request):
|
||||
"""Shell view for the My Sea standalone page.
|
||||
|
||||
Branches:
|
||||
Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
|
||||
hand storage AND 24h quota tracker. The row is created on first
|
||||
card draw + survives DEL (which only wipes the hand). View states:
|
||||
|
||||
1. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase
|
||||
w. saved hand + Brief banner + DEL guard portal. The draw's sig
|
||||
snapshot is rendered (NOT user.significator) so a cleared sig
|
||||
elsewhere doesn't invalidate the saved draw.
|
||||
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
|
||||
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
|
||||
click swaps data-phase to picker for a fresh draw.
|
||||
3a. + no equipped deck → also show backup-deck Brief banner.
|
||||
1. No sig → Look!-formatted sign-gate (Sprint 4b).
|
||||
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
|
||||
w. saved hand state. The DEL btn is server-rendered `.btn-
|
||||
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
|
||||
completion. The draw's sig snapshot is rendered (NOT user.
|
||||
significator) so a cleared sig elsewhere doesn't invalidate the
|
||||
saved draw.
|
||||
3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
|
||||
free-draw quota is spent; user must use tokens via the upcoming
|
||||
Sprint 6 gatekeeper). Brief banner still surfaces the next-
|
||||
free-draw timestamp.
|
||||
4. No active draw + sig set → landing w. FREE DRAW (the daily quota
|
||||
is available).
|
||||
4a. + no equipped deck → also show backup-deck Brief banner.
|
||||
"""
|
||||
active_draw = active_draw_for(request.user)
|
||||
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
||||
@@ -194,10 +202,22 @@ def my_sea(request):
|
||||
default_spread = active_draw.spread
|
||||
saved_hand = active_draw.hand
|
||||
next_free_draw_at = active_draw.next_free_draw_at
|
||||
hand_complete = active_draw.is_hand_complete
|
||||
hand_empty = active_draw.is_hand_empty
|
||||
else:
|
||||
default_spread = "situation-action-outcome"
|
||||
saved_hand = []
|
||||
next_free_draw_at = None
|
||||
hand_complete = False
|
||||
hand_empty = True
|
||||
# Picker is the active phase iff the user has a non-empty hand in
|
||||
# progress (or completed). Empty-hand active draws (post-DEL) fall
|
||||
# back to the landing — but render GATE VIEW instead of FREE DRAW
|
||||
# (the daily quota's spent already; landing's primary nav routes to
|
||||
# the upcoming gatekeeper). New users + post-24h users land on the
|
||||
# standard FREE DRAW landing.
|
||||
show_picker = active_draw is not None and not hand_empty
|
||||
quota_spent = active_draw is not None # any active row = quota committed
|
||||
|
||||
# Per-position lookup for the template — keyed by the position slug
|
||||
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
||||
@@ -234,11 +254,14 @@ def my_sea(request):
|
||||
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
||||
if user_has_sig else {"levity": [], "gravity": []}
|
||||
),
|
||||
# Iter 4b
|
||||
# Iter 4b / 4c
|
||||
"active_draw": active_draw,
|
||||
"saved_hand": saved_hand,
|
||||
"saved_by_position": saved_by_position,
|
||||
"next_free_draw_at": next_free_draw_at,
|
||||
"hand_complete": hand_complete,
|
||||
"show_picker": show_picker,
|
||||
"quota_spent": quota_spent,
|
||||
"page_class": "page-gameboard page-my-sea",
|
||||
})
|
||||
|
||||
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_lock(request):
|
||||
"""Persist the user's just-drawn hand as a `MySeaDraw` row.
|
||||
"""Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
|
||||
fires on every card placement (manual FLIP or AUTO DRAW completion)
|
||||
rather than only on a discrete LOCK HAND action.
|
||||
|
||||
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
|
||||
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
|
||||
400 for malformed payload, 409 if the user is still within the free-
|
||||
draw cooldown window (existing active draw)."""
|
||||
polarity}, ...]}` — `hand` is the current FULL state (partial OK
|
||||
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
|
||||
|
||||
Returns:
|
||||
200 `{ok, next_free_draw_at, hand_complete}` on success
|
||||
400 malformed payload or no sig
|
||||
409 spread differs from the user's already-active draw's spread
|
||||
(the spread is locked at first-card moment; can't switch mid-
|
||||
draw via a sneaky POST)
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
@@ -272,10 +304,26 @@ def my_sea_lock(request):
|
||||
hand = payload.get("hand")
|
||||
if not spread or not isinstance(hand, list) or not hand:
|
||||
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
|
||||
if spread not in HAND_SIZE_BY_SPREAD:
|
||||
return JsonResponse({"error": "unknown_spread"}, status=400)
|
||||
|
||||
if active_draw_for(request.user) is not None:
|
||||
return JsonResponse({"error": "quota_active"}, status=409)
|
||||
existing = active_draw_for(request.user)
|
||||
if existing is not None:
|
||||
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
|
||||
# through the gatekeeper but the endpoint stays permissive here).
|
||||
# Spread-switch attempts get 409 — the spread is committed at
|
||||
# first-card moment.
|
||||
if existing.spread != spread:
|
||||
return JsonResponse({"error": "spread_mismatch"}, status=409)
|
||||
existing.hand = hand
|
||||
existing.save(update_fields=["hand"])
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
|
||||
"hand_complete": existing.is_hand_complete,
|
||||
})
|
||||
|
||||
# First card draw → quota commit. Create the row.
|
||||
sig_id = request.user.significator_id
|
||||
if sig_id is None:
|
||||
return JsonResponse({"error": "no_significator"}, status=400)
|
||||
@@ -290,18 +338,35 @@ def my_sea_lock(request):
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
||||
"hand_complete": draw.is_hand_complete,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_delete(request):
|
||||
"""Delete the user's active draw — invoked by the DEL guard portal's
|
||||
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
|
||||
MySeaDraw.objects.filter(user=request.user).delete()
|
||||
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
|
||||
so the 24h quota window keeps running. Per user spec (2026-05-20):
|
||||
DEL doesn't refund the daily free-draw; the row stays as a quota
|
||||
tracker until 24h elapse, after which `active_draw_for`'s lazy
|
||||
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
|
||||
|
||||
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
|
||||
draw = active_draw_for(request.user)
|
||||
if draw is not None:
|
||||
draw.hand = []
|
||||
draw.save(update_fields=["hand"])
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_gate(request):
|
||||
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
|
||||
button-target placeholder lets the template's GATE VIEW UX wire up
|
||||
in advance; Sprint 6 will replace this w. the token-deposit flow."""
|
||||
return HttpResponse(status=404)
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Card payload shape is whatever
|
||||
|
||||
Reference in New Issue
Block a user