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

@@ -24,6 +24,48 @@ def stack_reversal_probability(user=None, room=None):
return STACK_REVERSAL_PROBABILITY return STACK_REVERSAL_PROBABILITY
def card_dict(card, reversal_prob=STACK_REVERSAL_PROBABILITY):
"""Canonical serialization of a TarotCard → JSON payload.
Single source of truth for the gameroom `sea_deck` endpoint AND the
`_my_sea_deck_data` helper on /gameboard/my-sea/. Iter 4b of the My
Sea roadmap extracted this from two near-identical copies that had
drifted on the Major Arcana polarity-split keys (cards 19-21 + 48-49)
— keeping the contract in one place prevents the same drift recurring.
The `reversed` flag is rolled fresh each call via `random.random()`
against `reversal_prob`. Callers that need a deterministic flag (e.g.
re-rendering a previously-saved hand) should NOT use this helper —
look up the card and serialize manually with the persisted flag.
"""
import random as _random
return {
'id': card.id,
'name': card.name,
'arcana': card.arcana,
'suit': card.suit,
'number': card.number,
'corner_rank': card.corner_rank,
'suit_icon': card.suit_icon,
'name_group': card.name_group,
'name_title': card.name_title,
'levity_qualifier': card.levity_qualifier,
'gravity_qualifier': card.gravity_qualifier,
'reversal_qualifier': card.reversal_qualifier,
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
'levity_emanation': card.levity_emanation,
'gravity_emanation': card.gravity_emanation,
'levity_reversal': card.levity_reversal,
'gravity_reversal': card.gravity_reversal,
'italic_word': card.italic_word,
'keywords_upright': card.keywords_upright,
'keywords_reversed': card.keywords_reversed,
'energies': card.energies,
'operations': card.operations,
'reversed': _random.random() < reversal_prob,
}
# Element key → in-game capacitor name (mirrors ELEMENT_INFO in sky-wheel.js). # Element key → in-game capacitor name (mirrors ELEMENT_INFO in sky-wheel.js).
# Used by the SKY_SAVED provenance event to render prose like # Used by the SKY_SAVED provenance event to render prose like

View File

@@ -22,7 +22,7 @@ from apps.epic.models import (
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
select_token, sig_deck_cards, select_token, sig_deck_cards,
) )
from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability, top_capacitors from apps.epic.utils import _compute_distinctions, _planet_house, card_dict, stack_reversal_probability, top_capacitors
from apps.lyric.models import Token from apps.lyric.models import Token
@@ -1281,43 +1281,14 @@ def sea_deck(request, room_id):
# per-user-profile config rides this same helper. # per-user-profile config rides this same helper.
reversal_prob = stack_reversal_probability(request.user, room) reversal_prob = stack_reversal_probability(request.user, room)
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 (cards 48-49 + trumps 19-21)
'levity_emanation': c.levity_emanation,
'gravity_emanation': c.gravity_emanation,
'levity_reversal': c.levity_reversal,
'gravity_reversal': c.gravity_reversal,
# Word inside any title slot to wrap in <em> at render time
'italic_word': c.italic_word,
'keywords_upright': c.keywords_upright,
'keywords_reversed': c.keywords_reversed,
'energies': c.energies,
'operations': c.operations,
# Pre-rolled reversal axis — server-deterministic, client just reads
'reversed': _random.random() < reversal_prob,
}
available = list( available = list(
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids) TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
) )
_random.shuffle(available) _random.shuffle(available)
mid = len(available) // 2 mid = len(available) // 2
return JsonResponse({ return JsonResponse({
'levity': [_card_dict(c) for c in available[:mid]], 'levity': [card_dict(c, reversal_prob) for c in available[:mid]],
'gravity': [_card_dict(c) for c in available[mid:]], 'gravity': [card_dict(c, reversal_prob) for c in available[mid:]],
}) })

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.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")) response = self.client.get(reverse("my_sea"))
deck = response.context["sea_deck_data"] deck = response.context["sea_deck_data"]
self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0) 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/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('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
path('my-sea/', views.my_sea, name='my_sea'), 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.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.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from .models import MySeaDraw, active_draw_for
def _annotate_deck_in_use(decks, user): def _annotate_deck_in_use(decks, user):
@@ -170,106 +174,165 @@ def toggle_game_kit_sections(request):
def my_sea(request): def my_sea(request):
"""Shell view for the My Sea standalone page. """Shell view for the My Sea standalone page.
Branches three ways: Branches:
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b). 1. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w. w. saved hand + Brief banner + DEL guard portal. The draw's sig
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps snapshot is rendered (NOT user.significator) so a cleared sig
data-phase to picker (the picker UX itself lands in iter 2). elsewhere doesn't invalidate the saved draw.
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning' 2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
Brief banner identical to the one on /billboard/my-sign/ (the user 3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
is headed for a draw against the Earthman [Shabby Cardstock] click swaps data-phase to picker for a fresh draw.
backup deck unless they equip one first). 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 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", { return render(request, "apps/gameboard/my_sea.html", {
"user_has_sig": user_has_sig, "user_has_sig": user_has_sig,
"no_equipped_deck": no_equipped_deck, "no_equipped_deck": no_equipped_deck,
"show_backup_intro_banner": user_has_sig and no_equipped_deck, "show_backup_intro_banner": (
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the user_has_sig and no_equipped_deck and active_draw is None
# picker phase. Template guards on `user_has_sig` so a None pass- ),
# through is safe; we pass the FK directly so `.corner_rank` + "significator": sig_card,
# `.suit_icon` resolve at render time. "significator_reversed": sig_reversed,
"significator": request.user.significator, "default_spread": default_spread,
"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",
"reversals_pct": 25, "reversals_pct": 25,
# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, sig "sea_deck_data": (
# excluded) for the client-side card-draw mechanic. Embedded in _my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
# the template via `{{ sea_deck_data|json_script:"..." }}`; JS if user_has_sig else {"levity": [], "gravity": []}
# reads on init + maintains the in-progress hand state client- ),
# side. Persistence (LOCK HAND → POST) lands in iter 4b. # Iter 4b
"sea_deck_data": _my_sea_deck_data(request.user) if user_has_sig else {"levity": [], "gravity": []}, "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", "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 """Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Mirrors the gameroom `epic.views.sea_ picker's card-draw mechanic. Card payload shape is whatever
deck` endpoint's card_dict shape so iter 4b's render/persist path `apps.epic.utils.card_dict` defines (single source of truth shared
can reuse the same JSON contract. w. the gameroom `sea_deck` endpoint).
Differences from the room version: Differences from the room version:
- No `room` context — exclude only the current user's significator - No `room` context — exclude only the sig card (no other seated
(no other seated gamers to worry about). 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, - Backup-deck fallthrough: if the user's `equipped_deck` is None,
fall back to Earthman (mirrors `personal_sig_cards`). 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 import random
from apps.epic.models import DeckVariant, TarotCard 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() deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
if not deck: if not deck:
return {"levity": [], "gravity": []} return {"levity": [], "gravity": []}
if exclude_id is None:
exclude_id = user.significator_id
available = list(TarotCard.objects.filter(deck_variant=deck)) available = list(TarotCard.objects.filter(deck_variant=deck))
if user.significator_id: if exclude_id:
available = [c for c in available if c.id != user.significator_id] available = [c for c in available if c.id != exclude_id]
random.shuffle(available) random.shuffle(available)
mid = len(available) // 2 mid = len(available) // 2
reversal_prob = 0.25 reversal_prob = stack_reversal_probability(user)
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,
}
return { return {
"levity": [_card_dict(c) for c in available[:mid]], "levity": [card_dict(c, reversal_prob) for c in available[:mid]],
"gravity": [_card_dict(c) for c in available[mid:]], "gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
} }

View File

@@ -872,31 +872,26 @@ class MySeaCardDrawTest(FunctionalTest):
# ── Test 8 ─────────────────────────────────────────────────────────────── # ── Test 8 ───────────────────────────────────────────────────────────────
def test_switching_spread_resets_in_progress_hand(self): def test_first_draw_locks_spread_combobox(self):
"""Picking a different spread on the combobox mid-draw resets """Per the iter-4a follow-up spec lock (2026-05-19): once the
the hand — different spreads use different position subsets + first card lands, the SPREAD combobox carries `.sea-select--
different hand-sizes, so an in-progress hand can't carry over.""" locked` so mid-draw spread switching is prevented (it would
scramble the position→card mapping). DEL releases the lock.
Was previously `test_switching_spread_resets_in_progress_hand`
— that test's premise (mid-draw spread switch resets hand) is
obsolete now that switching is blocked outright."""
picker = self._enter_picker_phase() picker = self._enter_picker_phase()
self._draw_one(picker, "levity") self._draw_one(picker, "levity")
self.assertEqual(
len(picker.find_elements(
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
)),
1,
)
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]") combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
combo.click()
picker.find_element(
By.CSS_SELECTOR,
".sea-select-list [role='option'][data-value='mind-body-spirit']",
).click()
self.wait_for( self.wait_for(
lambda: self.assertEqual( lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
len(picker.find_elements( )
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled" # DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`).
)), delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
0, delbtn.click()
) self.wait_for(
lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class"))
) )
# ── Test 4 ─────────────────────────────────────────────────────────────── # ── Test 4 ───────────────────────────────────────────────────────────────
@@ -995,3 +990,189 @@ class MySeaCardDrawTest(FunctionalTest):
By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block" By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
) )
self.assertIsNotNone(stat_block) self.assertIsNotNone(stat_block)
class MySeaLockHandTest(FunctionalTest):
"""Sprint 5 iter 4b — server persistence + DEL guard.
Iter 4a left the locked hand purely client-side; this iter persists
it via a `MySeaDraw` model so:
- reload restores the locked hand (picker renders w. all positions
already filled + locked)
- a 24-hour free-draw quota applies (user gets 1 draw per 24h
irrespective of spread type)
- the landing phase is bypassed when a saved draw exists
- DEL on a locked hand opens a uniform guard portal (CONFIRM/NVM)
- a Brief banner accompanies the picker post-lock w. the next
free-draw timestamp + NVM to dismiss
Per-modal interactivity (NVM dismiss UX, button-enabled state on
saved-hand init) defers to Jasmine — this FT pins only the
integration paths the server is responsible for.
"""
def setUp(self):
super().setUp()
_seed_earthman_sig_pile()
_seed_gameboard_applets()
self.email = "lock@test.io"
self.gamer = User.objects.create(email=self.email)
self.target_card = _assign_sig(self.gamer)
def _save_draw_for_user(self, hand=None):
"""Persist a MySeaDraw row for self.gamer directly, bypassing the
LOCK HAND UI. Returns the saved draw. Used by tests that pin the
post-lock UX without re-walking the 3-card draw flow each time."""
from apps.gameboard.models import MySeaDraw
if hand is None:
# Pick three cards from the user's deck (excluding sig)
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(
id=self.target_card.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 MySeaDraw.objects.create(
user=self.gamer,
spread="situation-action-outcome",
hand=hand,
significator_id=self.target_card.id,
significator_reversed=False,
)
# ── Test 1 ───────────────────────────────────────────────────────────────
def test_saved_draw_bypasses_landing_renders_picker_phase_directly(self):
"""User with a MySeaDraw row lands directly on [data-phase='picker']
— the landing (FREE DRAW + 6-chair hex) is skipped, since the
free quota is already spent and the locked hand is what the user
should see."""
self._save_draw_for_user()
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
page = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
self.assertIsNotNone(page)
# FREE DRAW landing chair-hex should not be visible.
landings = self.browser.find_elements(
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
)
self.assertEqual(landings, [])
# ── Test 2 ───────────────────────────────────────────────────────────────
def test_saved_draw_renders_saved_hand_in_picker_slots(self):
"""The picker phase renders each saved position's slot as
`--filled` + carries the saved card's id in `data-card-id` +
the saved polarity class (`--gravity` / `--levity`)."""
draw = self._save_draw_for_user()
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
for entry in draw.hand:
slot = self.browser.find_element(
By.CSS_SELECTOR,
f".sea-pos-{entry['position']} .sea-card-slot.sea-card-slot--filled",
)
self.assertEqual(
slot.get_attribute("data-card-id"), str(entry["card_id"]),
f"slot for position {entry['position']} should carry the saved card id",
)
self.assertIn(
f"sea-card-slot--{entry['polarity']}",
slot.get_attribute("class"),
)
# ── Test 3 ───────────────────────────────────────────────────────────────
def test_saved_draw_renders_brief_banner_with_next_free_draw_timestamp(self):
"""Post-lock UX: a Look!-formatted Brief banner above the picker
informs the user when the next free draw is available + offers a
NVM button to dismiss. Mirrors the Brief banner shape from the
Baltimorean Note unlock + the my-sign default-deck warning."""
self._save_draw_for_user()
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
brief = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-brief"
)
)
text = brief.text
self.assertIn("Look!", text)
self.assertIn("free draw", text.lower())
# The timestamp is rendered inside a dedicated child so the JS
# NVM-dismiss handler can find + style it independently of the
# surrounding copy.
ts = brief.find_element(By.CSS_SELECTOR, ".my-sea-brief__timestamp")
self.assertTrue(ts.text.strip(), "brief should render a non-empty next-free-draw timestamp")
# NVM button is present (Jasmine pins the dismiss-on-click).
brief.find_element(By.CSS_SELECTOR, ".my-sea-brief__nvm")
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_del_click_opens_guard_portal_with_uniform_confirm_copy(self):
"""DEL on a locked hand opens `#id_my_sea_del_portal` — uniform
'Are you sure?' copy (no conditional quota wording; the Brief
banner carries that info separately) w. CONFIRM + NVM buttons."""
self._save_draw_for_user()
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
picker = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
delbtn.click()
portal = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_my_sea_del_portal"
)
)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
text = portal.text.lower()
self.assertIn("sure", text)
portal.find_element(By.CSS_SELECTOR, ".my-sea-del-portal__confirm")
portal.find_element(By.CSS_SELECTOR, ".my-sea-del-portal__nvm")
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
"""Clicking the portal's CONFIRM POSTs to the delete endpoint
→ server wipes the MySeaDraw row → reload lands on the FREE DRAW
landing again (no saved hand, no Brief banner, FREE DRAW btn
present)."""
from apps.gameboard.models import MySeaDraw
self._save_draw_for_user()
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
self.create_pre_authenticated_session(self.email)
self.browser.get(self.live_server_url + "/gameboard/my-sea/")
picker = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
)
)
picker.find_element(By.CSS_SELECTOR, "#id_sea_del").click()
confirm = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-del-portal__confirm"
)
)
confirm.click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
)
)
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0)

View File

@@ -136,6 +136,22 @@ class GatekeeperTest(FunctionalTest):
recipient = self.wait_for( recipient = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_recipient") lambda: self.browser.find_element(By.ID, "id_recipient")
) )
# Wait for the slide-out's `scaleX(0)→scaleX(1)` 0.2s animation
# to settle before clicking OK — the bounding rect is near-zero
# during the transition, and Firefox auto-scroll-into-view fails
# against a zero-width target w. ElementNotInteractableException
# on the OK click. `html.bud-open` is set synchronously at btn
# click but the panel's `transform` matrix arrives asynchronously
# via the CSS transition; we poll `getBoundingClientRect().width`
# >100px to wait for the actual layout to finish. Pipeline #316.
self.wait_for(
lambda: self.assertGreater(
self.browser.execute_script(
"return document.getElementById('id_bud_panel').getBoundingClientRect().width;"
),
100,
)
)
recipient.send_keys("friend@test.io") recipient.send_keys("friend@test.io")
self.browser.find_element( self.browser.find_element(
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"

View File

@@ -503,3 +503,76 @@ body.page-gameboard {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
// ── Iter 4b: Brief banner + DEL guard portal ─────────────────────────────────
// Brief banner — Look!-formatted strip above the picker whenever a saved
// draw occupies the user's free-quota slot. Shape mirrors .my-sea-sign-
// gate but sized as a banner (full-width, single line + actions row).
.my-sea-brief {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 1rem;
margin: 0.5rem 0 1rem;
background-color: rgba(var(--secUser), 0.65);
border: 0.1rem solid rgba(var(--terUser), 0.6);
border-radius: 0.4rem;
color: rgba(var(--terUser), 1);
font-size: 0.95rem;
&[hidden] { display: none; }
.my-sea-brief__line {
margin: 0;
flex: 1;
}
.my-sea-brief__timestamp {
font-weight: bold;
color: rgba(var(--ninUser), 1);
}
.my-sea-brief__nvm {
flex: 0 0 auto;
}
}
// DEL guard portal — fixed-position centered modal w. a uniform
// 'Are you sure?' prompt. CONFIRM POSTs to /gameboard/my-sea/delete;
// NVM closes the portal. The Brief banner above carries the quota-
// specific copy so this stays free of conditional text.
.my-sea-del-portal {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
&[hidden] { display: none; }
.my-sea-del-portal__panel {
background-color: rgba(var(--secUser), 0.95);
border: 0.15rem solid rgba(var(--terUser), 0.8);
border-radius: 0.5rem;
padding: 1.25rem 1.75rem;
min-width: 18rem;
text-align: center;
color: rgba(var(--terUser), 1);
}
.my-sea-del-portal__line {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: bold;
}
.my-sea-del-portal__actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
}

View File

@@ -0,0 +1,19 @@
{# Renders a single .sea-card-slot for the my-sea picker — either #}
{# an empty drop zone (default) or a server-pre-filled slot from a #}
{# saved MySeaDraw row. Used by iter 4b's saved-hand bypass. #}
{# #}
{# Args: #}
{# position — slug of the position (lay/cover/crown/leave/loom/cross) #}
{# saved — saved_by_position[position] | dict | None #}
{# crossing — bool; pass True for the cross slot (gets the #}
{# `.sea-card-slot--crossing` modifier in iter-4a HTML) #}
{% if saved %}
<div class="sea-card-slot sea-card-slot--filled sea-card-slot--visible sea-card-slot--{{ saved.polarity }}{% if saved.reversed %} sea-card-slot--reversed{% endif %}{% if crossing %} sea-card-slot--crossing{% endif %}"
data-card-id="{{ saved.card_id }}"
data-pos-key="{{ position }}">
<span class="fan-corner-rank">{{ saved.corner_rank }}</span>
{% if saved.suit_icon %}<i class="fa-solid {{ saved.suit_icon }}"></i>{% endif %}
</div>
{% else %}
<div class="sea-card-slot sea-card-slot--empty{% if crossing %} sea-card-slot--crossing{% endif %}"></div>
{% endif %}

View File

@@ -5,7 +5,7 @@
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %} {% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %} {% block content %}
<div class="my-sea-page" data-phase="landing"> <div class="my-sea-page" data-phase="{% if active_draw %}picker{% else %}landing{% endif %}">
{% if not user_has_sig %} {% if not user_has_sig %}
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #} {# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
{# significator — render a Look!-formatted Brief-style line w. #} {# significator — render a Look!-formatted Brief-style line w. #}
@@ -24,12 +24,15 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{% if not active_draw %}
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #} {# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
{# the room shell (.room-shell > .room-table > … > .table-hex) #} {# the room shell (.room-shell > .room-table > … > .table-hex) #}
{# w. 6 chair seats labeled 1C-6C as placeholders for the #} {# w. 6 chair seats labeled 1C-6C as placeholders for the #}
{# friend-invite feature per the My Sea roadmap architectural #} {# friend-invite feature per the My Sea roadmap architectural #}
{# anchor "Six chairs retained even in solo". DRAW SEA btn #} {# anchor "Six chairs retained even in solo". DRAW SEA btn #}
{# mirrors SCAN SIGN on /billboard/my-sign/. #} {# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #}
{# an active draw exists (iter 4b) — the picker phase is the #}
{# landing state once the user has spent their free quota. #}
<div class="my-sea-landing"> <div class="my-sea-landing">
<div class="room-shell"> <div class="room-shell">
<div id="id_game_table" class="room-table"> <div id="id_game_table" class="room-table">
@@ -65,6 +68,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{# Picker phase — per-spread flexible layout. Sig pins .sea- #} {# Picker phase — per-spread flexible layout. Sig pins .sea- #}
{# pos-core; the 6 surrounding positions all render in DOM #} {# pos-core; the 6 surrounding positions all render in DOM #}
@@ -82,7 +86,7 @@
{# exist in one DOM). FLIP click delegates to SeaDeal. #} {# exist in one DOM). FLIP click delegates to SeaDeal. #}
{# openStage(), which fills the slot AND opens the portaled #} {# openStage(), which fills the slot AND opens the portaled #}
{# stage modal w. SPIN / FYI controls. #} {# stage modal w. SPIN / FYI controls. #}
<div class="my-sea-picker" id="id_sea_overlay" style="display:none"> <div class="my-sea-picker{% if active_draw %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not active_draw %} style="display:none"{% endif %}>
<div class="sea-cards-col"> <div class="sea-cards-col">
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}"> <div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #} {# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
@@ -92,11 +96,11 @@
{# touch the slot's nearest edge. #} {# touch the slot's nearest edge. #}
<div class="sea-crucifix-cell sea-pos-crown"> <div class="sea-crucifix-cell sea-pos-crown">
<span class="sea-pos-label" data-position="crown">Outcome</span> <span class="sea-pos-label" data-position="crown">Outcome</span>
<div class="sea-card-slot sea-card-slot--empty"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="crown" saved=saved_by_position.crown crossing=False %}
</div> </div>
<div class="sea-crucifix-cell sea-pos-leave"> <div class="sea-crucifix-cell sea-pos-leave">
<span class="sea-pos-label" data-position="leave"></span> <span class="sea-pos-label" data-position="leave"></span>
<div class="sea-card-slot sea-card-slot--empty"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="leave" saved=saved_by_position.leave crossing=False %}
</div> </div>
<div class="sea-crucifix-cell sea-pos-core"> <div class="sea-crucifix-cell sea-pos-core">
<div class="sig-stage-card sea-sig-card" <div class="sig-stage-card sea-sig-card"
@@ -106,20 +110,20 @@
</div> </div>
<div class="sea-pos-cover"> <div class="sea-pos-cover">
<span class="sea-pos-label" data-position="cover">Action</span> <span class="sea-pos-label" data-position="cover">Action</span>
<div class="sea-card-slot sea-card-slot--empty"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cover" saved=saved_by_position.cover crossing=False %}
</div> </div>
<div class="sea-pos-cross"> <div class="sea-pos-cross">
<span class="sea-pos-label" data-position="cross"></span> <span class="sea-pos-label" data-position="cross"></span>
<div class="sea-card-slot sea-card-slot--empty sea-card-slot--crossing"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cross" saved=saved_by_position.cross crossing=True %}
</div> </div>
</div> </div>
<div class="sea-crucifix-cell sea-pos-loom"> <div class="sea-crucifix-cell sea-pos-loom">
<span class="sea-pos-label" data-position="loom"></span> <span class="sea-pos-label" data-position="loom"></span>
<div class="sea-card-slot sea-card-slot--empty"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="loom" saved=saved_by_position.loom crossing=False %}
</div> </div>
<div class="sea-crucifix-cell sea-pos-lay"> <div class="sea-crucifix-cell sea-pos-lay">
<span class="sea-pos-label" data-position="lay">Situation</span> <span class="sea-pos-label" data-position="lay">Situation</span>
<div class="sea-card-slot sea-card-slot--empty"></div> {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="lay" saved=saved_by_position.lay crossing=False %}
</div> </div>
</div> </div>
</div> </div>
@@ -200,6 +204,40 @@
{# thumbnail. #} {# thumbnail. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %} {% include "apps/gameboard/_partials/_sea_stage.html" %}
</div> </div>
{# Iter 4b — Look!-formatted Brief banner above the picker. #}
{# Always rendered when the picker is rendered, but hidden #}
{# unless a saved draw occupies the user's free-quota slot #}
{# (server) OR LOCK HAND just fired (client un-hides on the #}
{# fetch response w. the next-free-draw timestamp). Avoiding #}
{# a full page reload on LOCK lets the iter-4a FTs keep their #}
{# picker element refs valid post-lock. #}
<div class="my-sea-brief"{% if not active_draw %} hidden{% endif %}>
<p class="my-sea-brief__line">
Look!&mdash;your free draw is locked in for the next 24 hours. Next free draw available at
<time class="my-sea-brief__timestamp"
datetime="{% if next_free_draw_at %}{{ next_free_draw_at|date:'c' }}{% endif %}">{% if next_free_draw_at %}{{ next_free_draw_at|date:'D, M j @ g:i A' }}{% endif %}</time>.
</p>
<button type="button" class="btn btn-cancel my-sea-brief__nvm">NVM</button>
</div>
{# Iter 4b — DEL guard portal. Uniform 'Are you sure?' copy #}
{# regardless of quota state (the Brief banner above carries #}
{# the quota-specific info). Always rendered (hidden by #}
{# default); DEL click un-hides when picker is `_locked`. #}
{# CONFIRM POSTs to /gameboard/my-sea/delete; NVM dismisses. #}
<div id="id_my_sea_del_portal" class="my-sea-del-portal" hidden>
<div class="my-sea-del-portal__panel">
<p class="my-sea-del-portal__line">Are you sure?</p>
<div class="my-sea-del-portal__actions">
<button type="button"
class="btn btn-cancel my-sea-del-portal__nvm">NVM</button>
<button type="button"
class="btn btn-danger my-sea-del-portal__confirm"
data-delete-url="{% url 'my_sea_delete' %}">CONFIRM</button>
</div>
</div>
</div>
{# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #} {# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #}
{# sig excluded) embedded as JSON; JS reads on init and #} {# sig excluded) embedded as JSON; JS reads on init and #}
{# pops from the relevant pile on each deposit. #} {# pops from the relevant pile on each deposit. #}
@@ -385,9 +423,15 @@
function _setLocked(on) { function _setLocked(on) {
_locked = on; _locked = on;
picker.classList.toggle('my-sea-picker--locked', on); picker.classList.toggle('my-sea-picker--locked', on);
// Decks + LOCK HAND go disabled; DEL stays interactive
// — it's the user's escape from the locked state
// (opens the guard portal in iter 4b, resets the hand
// in iter 4a). Without this exemption the SCSS rule
// `.btn-disabled { pointer-events: none }` would block
// every Selenium / user click on DEL post-LOCK.
[picker.querySelector('.sea-deck-stack--levity'), [picker.querySelector('.sea-deck-stack--levity'),
picker.querySelector('.sea-deck-stack--gravity'), picker.querySelector('.sea-deck-stack--gravity'),
delBtn, lockBtn].forEach(function (el) { lockBtn].forEach(function (el) {
if (!el) return; if (!el) return;
el.classList.toggle('btn-disabled', on); el.classList.toggle('btn-disabled', on);
}); });
@@ -445,19 +489,142 @@
// Click elsewhere inside the picker dismisses the FLIP btn. // Click elsewhere inside the picker dismisses the FLIP btn.
picker.addEventListener('click', _hideOk); picker.addEventListener('click', _hideOk);
// ── DEL semantics differ by lock state ──────────────────
// Pre-lock: DEL resets the in-progress hand client-side
// (iter 4a behaviour — no server round-trip).
// Post-lock: DEL opens `#id_my_sea_del_portal` guard portal
// (iter 4b). The portal CONFIRM POSTs to
// /gameboard/my-sea/delete; NVM closes the
// portal.
if (delBtn) { if (delBtn) {
delBtn.addEventListener('click', function () { delBtn.addEventListener('click', function (e) {
if (_locked) return; e.stopPropagation();
var portal = document.getElementById('id_my_sea_del_portal');
if (_locked && portal) {
portal.hidden = false;
return;
}
_resetHand(); _resetHand();
}); });
} }
if (lockBtn) { if (lockBtn) {
lockBtn.addEventListener('click', function () { lockBtn.addEventListener('click', function () {
if (lockBtn.disabled) return; if (lockBtn.disabled || _locked) return;
// Collect the in-progress hand for the POST. Slot
// class includes `--levity` / `--gravity`; reversed
// is on `.sea-card-slot--reversed`. Position comes
// from `data-pos-key`. Card id from `data-card-id`.
var hand = [];
cross.querySelectorAll(
'.sea-card-slot.sea-card-slot--filled'
).forEach(function (slot) {
var cls = slot.className;
hand.push({
position: slot.dataset.posKey || '',
card_id: parseInt(slot.dataset.cardId, 10),
reversed: /sea-card-slot--reversed\b/.test(cls),
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
});
});
var order = _currentOrder();
if (hand.length < order.length) return;
_setLocked(true); _setLocked(true);
_postLock(hand);
}); });
} }
function _csrf() {
var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
function _formatTimestamp(iso) {
// Mirror the server-side `D, M j @ g:i A` format used in
// the template's pre-rendered next-free-draw timestamp
// (e.g., "Thu, May 21 @ 2:41 AM"). Keeps the post-LOCK
// visual consistent with a fresh-page-load saved-draw
// render.
var d = new Date(iso);
if (isNaN(d)) return '';
var DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'];
var h = d.getHours();
var ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12; if (h === 0) h = 12;
var m = d.getMinutes();
var mm = (m < 10 ? '0' : '') + m;
return DAYS[d.getDay()] + ', ' + MONTHS[d.getMonth()]
+ ' ' + d.getDate() + ' @ ' + h + ':' + mm + ' ' + ampm;
}
function _revealBrief(nextFreeDrawIso) {
var brief = document.querySelector('.my-sea-brief');
if (!brief) return;
var ts = brief.querySelector('.my-sea-brief__timestamp');
if (ts && nextFreeDrawIso) {
ts.setAttribute('datetime', nextFreeDrawIso);
ts.textContent = _formatTimestamp(nextFreeDrawIso);
}
brief.hidden = false;
}
function _postLock(hand) {
fetch('{% url "my_sea_lock" %}', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': _csrf(),
},
body: JSON.stringify({
spread: hidden.value,
hand: hand,
}),
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (body) {
if (body && body.next_free_draw_at) {
_revealBrief(body.next_free_draw_at);
}
});
}
// ── DEL guard portal wiring ────────────────────────────
var portal = document.getElementById('id_my_sea_del_portal');
if (portal) {
var nvmBtn = portal.querySelector('.my-sea-del-portal__nvm');
var confirmBtn = portal.querySelector('.my-sea-del-portal__confirm');
if (nvmBtn) {
nvmBtn.addEventListener('click', function (e) {
e.stopPropagation();
portal.hidden = true;
});
}
if (confirmBtn) {
confirmBtn.addEventListener('click', function (e) {
e.stopPropagation();
var url = confirmBtn.dataset.deleteUrl || '';
if (!url) return;
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': _csrf()},
}).then(function (r) {
if (r.ok) window.location.reload();
});
});
}
}
// ── Brief banner NVM ──────────────────────────────────
var brief = document.querySelector('.my-sea-brief');
if (brief) {
var briefNvm = brief.querySelector('.my-sea-brief__nvm');
if (briefNvm) {
briefNvm.addEventListener('click', function () {
brief.hidden = true;
});
}
}
function syncLabels(spread) { function syncLabels(spread) {
var labels = POSITION_LABELS[spread] || {}; var labels = POSITION_LABELS[spread] || {};
cross.querySelectorAll('.sea-pos-label').forEach(function (el) { cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
@@ -475,10 +642,19 @@
hidden.addEventListener('change', sync); hidden.addEventListener('change', sync);
// Initial state — labels already server-rendered for the // Initial state — labels already server-rendered for the
// default spread; we just zero the hand counter + ensure // default spread. If the picker was server-rendered w. a
// LOCK HAND starts disabled. // locked saved hand (iter 4b's active_draw branch), the
// .my-sea-picker--locked class is already on the page —
// mirror it in JS state so further interactions stay
// disabled + DEL routes to the guard portal.
_filled = 0; _filled = 0;
if (lockBtn) lockBtn.disabled = true; var _preLocked = picker.classList.contains('my-sea-picker--locked');
if (_preLocked) {
_filled = _currentOrder().length;
_setLocked(true);
_lockSpread();
}
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
// Belt-and-braces autofill defense (paired w. autocomplete= // Belt-and-braces autofill defense (paired w. autocomplete=
// off on the hidden input above). Firefox occasionally // off on the hidden input above). Firefox occasionally