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:
@@ -24,6 +24,48 @@ def stack_reversal_probability(user=None, room=None):
|
||||
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).
|
||||
# Used by the SKY_SAVED provenance event to render prose like
|
||||
|
||||
@@ -22,7 +22,7 @@ from apps.epic.models import (
|
||||
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_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
|
||||
|
||||
|
||||
@@ -1281,43 +1281,14 @@ def sea_deck(request, room_id):
|
||||
# per-user-profile config rides this same helper.
|
||||
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(
|
||||
TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
|
||||
)
|
||||
_random.shuffle(available)
|
||||
mid = len(available) // 2
|
||||
return JsonResponse({
|
||||
'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:]],
|
||||
})
|
||||
|
||||
|
||||
|
||||
33
src/apps/gameboard/migrations/0001_initial.py
Normal file
33
src/apps/gameboard/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
src/apps/gameboard/migrations/__init__.py
Normal file
0
src/apps/gameboard/migrations/__init__.py
Normal 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()
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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):
|
||||
reversal_prob = stack_reversal_probability(user)
|
||||
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 {
|
||||
"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:]],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -872,31 +872,26 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
|
||||
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_switching_spread_resets_in_progress_hand(self):
|
||||
"""Picking a different spread on the combobox mid-draw resets
|
||||
the hand — different spreads use different position subsets +
|
||||
different hand-sizes, so an in-progress hand can't carry over."""
|
||||
def test_first_draw_locks_spread_combobox(self):
|
||||
"""Per the iter-4a follow-up spec lock (2026-05-19): once the
|
||||
first card lands, the SPREAD combobox carries `.sea-select--
|
||||
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()
|
||||
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.click()
|
||||
picker.find_element(
|
||||
By.CSS_SELECTOR,
|
||||
".sea-select-list [role='option'][data-value='mind-body-spirit']",
|
||||
).click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
0,
|
||||
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
# DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`).
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
@@ -995,3 +990,189 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, "#id_sea_stage .sea-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)
|
||||
|
||||
@@ -136,6 +136,22 @@ class GatekeeperTest(FunctionalTest):
|
||||
recipient = self.wait_for(
|
||||
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")
|
||||
self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||
|
||||
@@ -503,3 +503,76 @@ body.page-gameboard {
|
||||
opacity: 0.5;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
19
src/templates/apps/gameboard/_partials/_my_sea_slot.html
Normal file
19
src/templates/apps/gameboard/_partials/_my_sea_slot.html
Normal 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 %}
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% 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 %}
|
||||
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
||||
{# significator — render a Look!-formatted Brief-style line w. #}
|
||||
@@ -24,12 +24,15 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if not active_draw %}
|
||||
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
||||
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
|
||||
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
|
||||
{# friend-invite feature per the My Sea roadmap architectural #}
|
||||
{# 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="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
@@ -65,6 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Picker phase — per-spread flexible layout. Sig pins .sea- #}
|
||||
{# pos-core; the 6 surrounding positions all render in DOM #}
|
||||
@@ -82,7 +86,7 @@
|
||||
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
|
||||
{# openStage(), which fills the slot AND opens the portaled #}
|
||||
{# 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-cross my-sea-cross" data-spread="{{ default_spread }}">
|
||||
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
|
||||
@@ -92,11 +96,11 @@
|
||||
{# touch the slot's nearest edge. #}
|
||||
<div class="sea-crucifix-cell sea-pos-crown">
|
||||
<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 class="sea-crucifix-cell sea-pos-leave">
|
||||
<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 class="sea-crucifix-cell sea-pos-core">
|
||||
<div class="sig-stage-card sea-sig-card"
|
||||
@@ -106,20 +110,20 @@
|
||||
</div>
|
||||
<div class="sea-pos-cover">
|
||||
<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 class="sea-pos-cross">
|
||||
<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 class="sea-crucifix-cell sea-pos-loom">
|
||||
<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 class="sea-crucifix-cell sea-pos-lay">
|
||||
<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>
|
||||
@@ -200,6 +204,40 @@
|
||||
{# thumbnail. #}
|
||||
{% include "apps/gameboard/_partials/_sea_stage.html" %}
|
||||
</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!—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, #}
|
||||
{# sig excluded) embedded as JSON; JS reads on init and #}
|
||||
{# pops from the relevant pile on each deposit. #}
|
||||
@@ -385,9 +423,15 @@
|
||||
function _setLocked(on) {
|
||||
_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--gravity'),
|
||||
delBtn, lockBtn].forEach(function (el) {
|
||||
lockBtn].forEach(function (el) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('btn-disabled', on);
|
||||
});
|
||||
@@ -445,17 +489,140 @@
|
||||
// Click elsewhere inside the picker dismisses the FLIP btn.
|
||||
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) {
|
||||
delBtn.addEventListener('click', function () {
|
||||
if (_locked) return;
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var portal = document.getElementById('id_my_sea_del_portal');
|
||||
if (_locked && portal) {
|
||||
portal.hidden = false;
|
||||
return;
|
||||
}
|
||||
_resetHand();
|
||||
});
|
||||
}
|
||||
if (lockBtn) {
|
||||
lockBtn.addEventListener('click', function () {
|
||||
if (lockBtn.disabled) return;
|
||||
_setLocked(true);
|
||||
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);
|
||||
_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) {
|
||||
@@ -475,10 +642,19 @@
|
||||
hidden.addEventListener('change', sync);
|
||||
|
||||
// Initial state — labels already server-rendered for the
|
||||
// default spread; we just zero the hand counter + ensure
|
||||
// LOCK HAND starts disabled.
|
||||
// default spread. If the picker was server-rendered w. a
|
||||
// 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;
|
||||
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=
|
||||
// off on the hidden input above). Firefox occasionally
|
||||
|
||||
Reference in New Issue
Block a user