My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD

Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring).

Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait.

Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5).

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-19 23:54:00 -04:00
parent 31ed2bda0e
commit b76d3c5dff
13 changed files with 1147 additions and 145 deletions

View File

@@ -1,9 +1,13 @@
import json
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
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
def _annotate_deck_in_use(decks, user):
@@ -170,106 +174,165 @@ def toggle_game_kit_sections(request):
def my_sea(request):
"""Shell view for the My Sea standalone page.
Branches three ways:
Branches:
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b).
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w.
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps
data-phase to picker (the picker UX itself lands in iter 2).
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning'
Brief banner identical to the one on /billboard/my-sign/ (the user
is headed for a draw against the Earthman [Shabby Cardstock]
backup deck unless they equip one first).
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.
"""
user_has_sig = request.user.significator_id is not None
active_draw = active_draw_for(request.user)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
user_has_sig = sig_card is not None
no_equipped_deck = request.user.equipped_deck_id is None
if active_draw is not None:
default_spread = active_draw.spread
saved_hand = active_draw.hand
next_free_draw_at = active_draw.next_free_draw_at
else:
default_spread = "situation-action-outcome"
saved_hand = []
next_free_draw_at = None
# Per-position lookup for the template — keyed by the position slug
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
# either its saved card OR an `--empty` slot via a single `{% with
# entry=saved_by_position.lay %}` block. The card fields (corner_rank,
# suit_icon) come pre-resolved so the template doesn't need to do a
# DB lookup per slot.
saved_by_position = {}
if saved_hand:
from apps.epic.models import TarotCard
ids = [e["card_id"] for e in saved_hand]
cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)}
for entry in saved_hand:
c = cards_by_id.get(entry["card_id"])
saved_by_position[entry["position"]] = {
"card_id": entry["card_id"],
"reversed": entry.get("reversed", False),
"polarity": entry.get("polarity", "gravity"),
"corner_rank": c.corner_rank if c else "",
"suit_icon": c.suit_icon if c else "",
}
return render(request, "apps/gameboard/my_sea.html", {
"user_has_sig": user_has_sig,
"no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": user_has_sig and no_equipped_deck,
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the
# picker phase. Template guards on `user_has_sig` so a None pass-
# through is safe; we pass the FK directly so `.corner_rank` +
# `.suit_icon` resolve at render time.
"significator": request.user.significator,
"significator_reversed": request.user.significator_reversed,
# Sprint 5 iter 3 — SPREAD dropdown defaults to Situation/Action/
# Outcome (a 3-card spread) per user-locked spec; `reversals_pct`
# is a placeholder UI value pending the per-user setting.
"default_spread": "situation-action-outcome",
"show_backup_intro_banner": (
user_has_sig and no_equipped_deck and active_draw is None
),
"significator": sig_card,
"significator_reversed": sig_reversed,
"default_spread": default_spread,
"reversals_pct": 25,
# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, sig
# excluded) for the client-side card-draw mechanic. Embedded in
# the template via `{{ sea_deck_data|json_script:"..." }}`; JS
# reads on init + maintains the in-progress hand state client-
# side. Persistence (LOCK HAND → POST) lands in iter 4b.
"sea_deck_data": _my_sea_deck_data(request.user) if user_has_sig else {"levity": [], "gravity": []},
"sea_deck_data": (
_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
"active_draw": active_draw,
"saved_hand": saved_hand,
"saved_by_position": saved_by_position,
"next_free_draw_at": next_free_draw_at,
"page_class": "page-gameboard page-my-sea",
})
def _my_sea_deck_data(user):
def _resolve_sig(user, active_draw):
"""When an active draw exists, render its sig snapshot — even if
user.significator has since been cleared (per user spec, preserve the
old sig on the saved draw). Otherwise use user.significator."""
if active_draw is not None:
from apps.epic.models import TarotCard
sig = TarotCard.objects.filter(id=active_draw.significator_id).first()
return sig, active_draw.significator_reversed
return user.significator, user.significator_reversed
@login_required(login_url="/")
@require_POST
def my_sea_lock(request):
"""Persist the user's just-drawn hand as a `MySeaDraw` row.
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)."""
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"error": "invalid_json"}, status=400)
spread = payload.get("spread")
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 active_draw_for(request.user) is not None:
return JsonResponse({"error": "quota_active"}, status=409)
sig_id = request.user.significator_id
if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400)
draw = MySeaDraw.objects.create(
user=request.user,
spread=spread,
hand=hand,
significator_id=sig_id,
significator_reversed=request.user.significator_reversed,
)
return JsonResponse({
"ok": True,
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
})
@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()
return HttpResponse(status=204)
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. Mirrors the gameroom `epic.views.sea_
deck` endpoint's card_dict shape so iter 4b's render/persist path
can reuse the same JSON contract.
picker's card-draw mechanic. Card payload shape is whatever
`apps.epic.utils.card_dict` defines (single source of truth shared
w. the gameroom `sea_deck` endpoint).
Differences from the room version:
- No `room` context — exclude only the current user's significator
(no other seated gamers to worry about).
- No `room` context — exclude only the sig card (no other seated
gamers to worry about). `exclude_id` defaults to `user.significator_id`
but callers can pass a draw's snapshotted sig id when the saved-
draw branch is rendering.
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
fall back to Earthman (mirrors `personal_sig_cards`).
- Reversal probability hardcoded to 0.25 per the iter 3 spec lock;
future per-user config rides on the shared `stack_reversal_
probability` helper.
"""
import random
from apps.epic.models import DeckVariant, TarotCard
from apps.epic.utils import card_dict, stack_reversal_probability
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
if not deck:
return {"levity": [], "gravity": []}
if exclude_id is None:
exclude_id = user.significator_id
available = list(TarotCard.objects.filter(deck_variant=deck))
if user.significator_id:
available = [c for c in available if c.id != user.significator_id]
if exclude_id:
available = [c for c in available if c.id != exclude_id]
random.shuffle(available)
mid = len(available) // 2
reversal_prob = 0.25
def _card_dict(c):
return {
"id": c.id,
"name": c.name,
"arcana": c.arcana,
"suit": c.suit,
"number": c.number,
"corner_rank": c.corner_rank,
"suit_icon": c.suit_icon,
"name_group": c.name_group,
"name_title": c.name_title,
"levity_qualifier": c.levity_qualifier,
"gravity_qualifier": c.gravity_qualifier,
"reversal_qualifier": c.reversal_qualifier,
# Polarity-split full-title overrides — required for Major
# Arcana (Earthman trumps 19-21 + cards 48-49) to render
# their per-polarity emanation/reversal names on the stage
# card. Without these StageCard.populateCard falls back to
# the plain `name_title` w. no qualifier. Mirrors the
# gameroom `epic.views.sea_deck` JSON shape exactly.
"levity_emanation": c.levity_emanation,
"gravity_emanation": c.gravity_emanation,
"levity_reversal": c.levity_reversal,
"gravity_reversal": c.gravity_reversal,
"italic_word": c.italic_word,
"keywords_upright": c.keywords_upright,
"keywords_reversed": c.keywords_reversed,
"energies": c.energies,
"operations": c.operations,
"reversed": random.random() < reversal_prob,
}
reversal_prob = stack_reversal_probability(user)
return {
"levity": [_card_dict(c) for c in available[:mid]],
"gravity": [_card_dict(c) for c in available[mid:]],
"levity": [card_dict(c, reversal_prob) for c in available[:mid]],
"gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
}