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:
@@ -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:]],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user