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

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-05-20 02:23
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MySeaDraw',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('spread', models.CharField(max_length=40)),
('hand', models.JSONField(default=list)),
('significator_id', models.IntegerField()),
('significator_reversed', models.BooleanField(default=False)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='my_sea_draws', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -1,3 +1,75 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
# Create your models here.
FREE_DRAW_COOLDOWN_HOURS = 24
class MySeaDraw(models.Model):
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
feature. Each row is one locked hand by one user.
Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side
persistence of the iter-4a client-side draw mechanic.
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
(24h, irrespective of spread type). Subsequent draws within the
window are intended to be gated behind a token deposit at the My
Sea gatekeeper, which Sprint 6 will build.
`hand` is an ordered list of position-dicts in draw order — Sprint 7's
applet renders them left-to-right in that order. Each entry shape:
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
`significator_id` + `significator_reversed` snapshot the user's sig
at lock time so a subsequent `User.significator = None` (via my-sign
DEL) doesn't invalidate the saved draw — per user spec, preserve the
old sig; any future draw uses whatever sig is current at that time.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="my_sea_draws",
)
spread = models.CharField(max_length=40)
hand = models.JSONField(default=list)
significator_id = models.IntegerField()
significator_reversed = models.BooleanField(default=False)
created_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"MySeaDraw({self.user_id}, {self.spread}, {self.created_at:%Y-%m-%d %H:%M})"
@property
def next_free_draw_at(self):
"""Datetime when the user's next free draw will be available
(created_at + FREE_DRAW_COOLDOWN_HOURS)."""
return self.created_at + timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
@property
def is_within_quota_window(self):
"""True iff this draw was created within the last
FREE_DRAW_COOLDOWN_HOURS — i.e., it currently occupies the user's
free-draw slot."""
return self.created_at >= (
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
)
def active_draw_for(user):
"""Return the user's most-recent draw within the quota window, or
None. Used both for rendering the picker w. saved hand on page load
and for gating LOCK HAND POSTs.
Importing this helper rather than re-deriving the cutoff in every
caller keeps the 24h window a single-source-of-truth tied to
FREE_DRAW_COOLDOWN_HOURS."""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
return MySeaDraw.objects.filter(
user=user, created_at__gte=cutoff,
).order_by("-created_at").first()

View File

@@ -797,3 +797,357 @@ class MySeaDeckDataViewTest(TestCase):
response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"]
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
# ── Sprint 5 iter 4b — server persistence: MySeaDraw + lock + delete ──────────
class MySeaDrawModelTest(TestCase):
"""Sprint 5 iter 4b — `MySeaDraw` model + `active_draw_for` helper."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="model@test.io")
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def _build_hand(self):
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
return [
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
]
def test_create_round_trips_hand_and_sig_snapshot(self):
from apps.gameboard.models import MySeaDraw
hand = self._build_hand()
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=hand, significator_id=self.target.id, significator_reversed=False,
)
draw.refresh_from_db()
self.assertEqual(draw.hand, hand)
self.assertEqual(draw.significator_id, self.target.id)
def test_next_free_draw_at_is_created_at_plus_24h(self):
from datetime import timedelta
from apps.gameboard.models import MySeaDraw, FREE_DRAW_COOLDOWN_HOURS
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
delta = draw.next_free_draw_at - draw.created_at
self.assertEqual(delta, timedelta(hours=FREE_DRAW_COOLDOWN_HOURS))
def test_is_within_quota_window_true_when_fresh(self):
from apps.gameboard.models import MySeaDraw
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertTrue(draw.is_within_quota_window)
def test_is_within_quota_window_false_when_older_than_24h(self):
from datetime import timedelta
from django.utils import timezone
from apps.gameboard.models import MySeaDraw
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertFalse(draw.is_within_quota_window)
def test_active_draw_for_returns_recent_draw(self):
from apps.gameboard.models import MySeaDraw, active_draw_for
draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertEqual(active_draw_for(self.user), draw)
def test_active_draw_for_returns_none_when_no_draws(self):
from apps.gameboard.models import active_draw_for
self.assertIsNone(active_draw_for(self.user))
def test_active_draw_for_returns_none_when_only_stale_draws(self):
from datetime import timedelta
from django.utils import timezone
from apps.gameboard.models import MySeaDraw, active_draw_for
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertIsNone(active_draw_for(self.user))
def test_active_draw_for_scopes_to_user(self):
from apps.gameboard.models import MySeaDraw, active_draw_for
other = User.objects.create(email="other@test.io")
MySeaDraw.objects.create(
user=other, spread="situation-action-outcome",
hand=self._build_hand(), significator_id=self.target.id,
)
self.assertIsNone(active_draw_for(self.user))
class MySeaLockHandViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/lock` persists a hand."""
def setUp(self):
from apps.epic.models import personal_sig_cards
self.user = User.objects.create(email="lock@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.url = reverse("my_sea_lock")
def _build_payload(self, spread="situation-action-outcome", hand=None):
from apps.epic.models import TarotCard
if hand is None:
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
hand = [
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
]
return {"spread": spread, "hand": hand}
def test_lock_requires_login(self):
import json
self.client.logout()
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_lock_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_lock_post_creates_my_sea_draw_for_user(self):
import json
from apps.gameboard.models import MySeaDraw
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
draw = MySeaDraw.objects.get(user=self.user)
self.assertEqual(draw.spread, "situation-action-outcome")
self.assertEqual(len(draw.hand), 3)
self.assertEqual(draw.significator_id, self.target.id)
def test_lock_post_response_includes_next_free_draw_iso_timestamp(self):
import json
from datetime import datetime
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
body = response.json()
self.assertIn("next_free_draw_at", body)
# Round-trip parse — the server is expected to send an ISO 8601 string.
parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed)
def test_lock_post_within_quota_window_returns_409(self):
# Second lock within 24h: the existing draw already occupies the
# quota; the server rejects rather than overwriting.
import json
from apps.gameboard.models import MySeaDraw
self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
def test_lock_post_empty_hand_returns_400(self):
import json
response = self.client.post(
self.url, data=json.dumps({"spread": "situation-action-outcome", "hand": []}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_lock_post_missing_spread_returns_400(self):
import json
payload = self._build_payload()
payload.pop("spread")
response = self.client.post(
self.url, data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_lock_post_snapshots_user_significator(self):
import json
from apps.gameboard.models import MySeaDraw
self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
draw = MySeaDraw.objects.get(user=self.user)
# Sig snapshot persists even after user clears their sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
draw.refresh_from_db()
self.assertEqual(draw.significator_id, self.target.id)
class MySeaDeleteDrawViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="del@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"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
],
)
self.url = reverse("my_sea_delete")
def test_delete_requires_login(self):
self.client.logout()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
def test_delete_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_delete_post_clears_active_draw(self):
from apps.gameboard.models import MySeaDraw
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
def test_delete_post_scoped_to_user_does_not_touch_others(self):
from apps.gameboard.models import MySeaDraw
other = User.objects.create(email="other-del@test.io")
other_draw = MySeaDraw.objects.create(
user=other, spread="situation-action-outcome",
hand=self.draw.hand, significator_id=self.target.id,
)
self.client.post(self.url)
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
def test_delete_post_idempotent_when_no_active_draw(self):
# User deletes twice in a row — second call is a no-op, not a 500.
self.client.post(self.url)
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
class MySeaViewWithSavedDrawTest(TestCase):
"""Sprint 5 iter 4b — `my_sea` view branches when an active draw exists.
Active draw bypasses the sign-gate (sig snapshot on the draw is used
even if `user.significator` is None), bypasses the landing phase (the
saved hand IS what the user came to see), and adds a Brief banner +
next-free-draw timestamp to the context."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="saved@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"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
{"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
],
)
def test_context_carries_active_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["active_draw"], self.draw)
def test_context_default_spread_is_saved_spread(self):
response = self.client.get(reverse("my_sea"))
self.assertEqual(response.context["default_spread"], self.draw.spread)
def test_context_carries_next_free_draw_iso(self):
from datetime import datetime
response = self.client.get(reverse("my_sea"))
ts = response.context["next_free_draw_at"]
# Either a datetime instance or an ISO string the template renders.
if isinstance(ts, str):
self.assertIsNotNone(datetime.fromisoformat(ts))
else:
self.assertIsNotNone(ts.isoformat())
def test_saved_draw_bypasses_sign_gate_even_when_user_sig_cleared(self):
# User-spec'd: a cleared sig doesn't invalidate the saved draw.
# The view must still render the picker phase (NOT the sign-gate)
# by falling back to the draw's snapshotted sig.
self.user.significator = None
self.user.save(update_fields=["significator"])
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertNotIn("my-sea-sign-gate", html)
self.assertIn('data-phase="picker"', html)
def test_view_renders_brief_banner_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "my-sea-brief")
self.assertContains(response, "my-sea-brief__timestamp")
self.assertContains(response, "my-sea-brief__nvm")
def test_brief_banner_hidden_without_active_draw(self):
# Markup is rendered unconditionally so JS can un-hide it on LOCK
# HAND POST success without a page reload. When no active_draw,
# the wrapping div carries `[hidden]` so the banner is invisible.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.all().delete()
response = self.client.get(reverse("my_sea"))
self.assertContains(response, '<div class="my-sea-brief" hidden>')
def test_view_renders_del_guard_portal_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_my_sea_del_portal"')
self.assertContains(response, "my-sea-del-portal__confirm")
self.assertContains(response, "my-sea-del-portal__nvm")
def test_saved_hand_renders_as_filled_slots_in_picker(self):
# Each saved position's slot is server-rendered as `--filled` w.
# the snapshotted card id + polarity. JS-init then layers any
# post-load behaviours (label re-rendering, stage-card lookups).
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for entry in self.draw.hand:
self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
self.assertIn(f"sea-card-slot--{entry['polarity']}", html)
def test_landing_phase_suppressed_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'data-phase="landing"')

View File

@@ -14,5 +14,7 @@ urlpatterns = [
path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'),
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
path('my-sea/', views.my_sea, name='my_sea'),
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
]

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:]],
}