Compare commits

...

3 Commits

Author SHA1 Message Date
Disco DeDisco
6f901fd9ce My Sea iter 4b polish v2: drop FYI from locked-draw Brief; dynamic aria-selected per default_spread; defensive cross data-spread sync on init (guards bf-cache drift causing all 6 slots to render post-DEL+reload) — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
(1) FYI removal — the locked-draw Brief is purely informational (status + next-free-draw timestamp). No navigation target made sense for the FYI; drop it after Brief.showBanner renders. The NVM dismiss + dedicated `<time>` slot carry all the affordance the user needs.

(2) Dynamic aria-selected on the SPREAD combobox — previously the SAO option was hardcoded `aria-selected="true"`. When active_draw is non-SAO (e.g. Celtic Cross), server-rendered state was internally inconsistent: hidden value = waite-smith, aria-selected = SAO. JS init's force-sync (which reads aria-selected to override autofill on hidden) then overwrote the correct hidden value w. SAO — corrupting the picker's state silently. Made aria-selected + `.sea-select-current` visible label both branch on `default_spread`.

(3) Defensive cross.data-spread sync on init — after the autofill force-sync settles `hidden.value` from the aria-selected source-of-truth, mirror it onto `.my-sea-cross[data-spread]` + re-run syncLabels. Idempotent when server-rendered state is internally consistent; corrective when a prior page state (Firefox bfcache restoring a Celtic-Cross DOM, mid-draw session restored) left a stale `data-spread` that SCSS-hides the wrong subset of cells. User-reported 2026-05-20: after locking a Celtic Cross + DEL + reload, all 6 slots remained visible on the picker w. SAO labels — exactly the symptom of cross.data-spread="waite-smith" surviving an otherwise-fresh server render.

Tests: 116 gameboard ITs + 5 iter-4b FTs green. The dynamic aria-selected behavior is implicitly covered by the existing default-spread IT (no regression on the SAO=true baseline); the bf-cache scenario is hard to express as a deterministic FT/IT — the defensive sync is a safety net, not a behavioral spec.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:27:50 -04:00
Disco DeDisco
c1a8133345 My Sea iter 4b polish: Brief banner uses standard portaled .note-banner (Gaussian glass atop h2); next-free-draw datetime in dedicated <time> slot (not "Invalid Date"); DEL guard reuses shared #id_guard_portal from base.html — TDD
UX refactor on top of iter 4b (b76d3c5) per user direction:

(1) Brief banner — replaced custom `.my-sea-brief` markup + SCSS w. a call to `Brief.showBanner` from note.js. Now matches the my-notes / my-sign default-deck-warning Briefs exactly: standard `.note-banner` portaled atop the h2 w. Gaussian-glass backdrop-filter blur. Tagged `.my-sea-locked-banner` for FT disambiguation only — no visual override.

(2) Brief timestamp — fix for "Invalid Date" rendering in note.js's `<time class="note-banner__timestamp">` slot. Previously passed `created_at: ''` to `Brief.showBanner` → `new Date('')` returns Invalid Date → `toLocaleDateString` renders "Invalid Date". Now passes the next-free-draw ISO timestamp as `created_at` (server emits via `|date:'c'`). After Brief.showBanner returns, the `_showFreeDrawLockedBrief` JS overwrites the rendered text w. the more detailed `D, M j @ g:i A` format ("Wed, May 20 @ 11:57 PM") — leaves the ISO `datetime=` attribute intact for accessibility. The `line_text` no longer carries the timestamp inline (it's redundant w. the dedicated slot).

(3) DEL guard portal — replaced custom `#id_my_sea_del_portal` fullscreen modal + `.my-sea-del-portal` SCSS w. a call to `window.showGuard` from base.html, targeting the shared `#id_guard_portal`. Same Gaussian-glass tooltip the room gear-menu DEL flow uses: no backdrop, positioned above the anchor button, standard `.btn-confirm OK` + `.btn-cancel NVM` pair. Bundled a non-breaking `options.yesLabel` extension to `show()` in base.html for future destructive flows that need a custom YES label (defaults to 'OK', resets on dismiss/confirm) — my-sea doesn't use it per user direction (the `.btn-confirm` class implies "OK"; destructive intent belongs on the trigger button, which is `.btn-danger DEL`).

Tests: 30 iter-4b ITs (model + lock + delete + saved-draw view branches) + 5 iter-4b FTs all green; IT/FT assertions updated to target the shared portal markup (`#id_guard_portal.active`, `.guard-yes`, `.guard-no`, `.note-banner.my-sea-locked-banner`).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:12:52 -04:00
Disco DeDisco
b76d3c5dff 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>
2026-05-19 23:54:32 -04:00
14 changed files with 1134 additions and 153 deletions

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -797,3 +797,376 @@ 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_triggers_brief_banner_when_active_draw_exists(self):
# Brief is rendered client-side via Brief.showBanner (standard
# `.note-banner` w. Gaussian-glass bg, portaled atop the h2 —
# same UX as my-notes / my-sign default-deck-warning Briefs).
# Server emits a `window._showFreeDrawLockedBrief("<iso>")` call
# gated on active_draw; ISO timestamp (`|date:'c'`) is re-used
# as both `created_at` AND the source for the human-formatted
# display string note.js renders in the `.note-banner__timestamp`
# slot — single source of truth, no "Invalid Date" on bad input.
response = self.client.get(reverse("my_sea"))
# Match the call form w. opening quote — the bare token
# `_showFreeDrawLockedBrief(` also appears in the function
# definition emitted unconditionally inside the picker IIFE.
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
# The ISO format produced by Django's `|date:'c'` starts with the
# full year + ISO-style T separator — pin a representative token.
from django.utils import timezone
from datetime import timedelta
expected_year = (timezone.now() + timedelta(hours=24)).strftime("%Y")
self.assertContains(response, '_showFreeDrawLockedBrief("' + expected_year)
def test_view_does_not_trigger_brief_banner_without_active_draw(self):
# Definition of `_showFreeDrawLockedBrief` is always emitted;
# only the CALL is gated on active_draw. Pin the call form.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.all().delete()
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'window._showFreeDrawLockedBrief("')
def test_view_wires_del_button_to_shared_guard_portal_when_active_draw(self):
# No my-sea-specific guard markup — the picker IIFE calls
# `window.showGuard(delBtn, "Are you sure?", confirmFn)` which
# targets the shared #id_guard_portal from base.html (same
# tooltip the room gear-menu uses; standard OK/NVM button pair).
# Server emits the call site; we pin the call form + the delete
# URL it POSTs to.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "window.showGuard(")
self.assertContains(response, reverse("my_sea_delete"))
def test_saved_hand_renders_as_filled_slots_in_picker(self):
# Each saved position's slot is server-rendered as `--filled` w.
# the snapshotted card id + polarity. JS-init then layers any
# post-load behaviours (label re-rendering, stage-card lookups).
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
for entry in self.draw.hand:
self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
self.assertIn(f"sea-card-slot--{entry['polarity']}", html)
def test_landing_phase_suppressed_when_active_draw_exists(self):
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'data-phase="landing"')

View File

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

View File

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

View File

@@ -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,198 @@ 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 appears atop the
h2 (standard portaled `.note-banner` w. Gaussian-glass bg, same
styling as my-notes / my-sign default-deck-warning Briefs). The
next-free-draw timestamp lives in the dedicated `.note-banner__
timestamp` `<time>` slot (note.js's standard datetime element),
formatted by JS to `D, M j @ g:i A` shape — e.g. "Wed, May 20 @
11:57 PM". Tagged `.my-sea-locked-banner` so this FT disambiguates
from any other Briefs that may stack on the page."""
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, ".note-banner.my-sea-locked-banner"
)
)
text = brief.text
self.assertIn("Look!", text)
self.assertIn("free draw", text.lower())
# Timestamp slot owns the next-free-draw datetime. The "@" token
# in the `D, M j @ g:i A` format is a stable assertion target;
# also pin the year to confirm the source ISO parsed correctly
# (would render "Invalid Date" if note.js got an empty string).
ts = brief.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
ts_text = ts.text
self.assertIn("@", ts_text)
self.assertNotIn("Invalid", ts_text)
# NVM dismiss button is wired by note.js itself.
brief.find_element(By.CSS_SELECTOR, ".note-banner__nvm")
# ── Test 4 ───────────────────────────────────────────────────────────────
def test_del_click_opens_shared_guard_portal(self):
"""DEL on a locked hand opens the shared `#id_guard_portal` from
base.html (same Gaussian-glass tooltip the room gear-menu uses)
w. uniform 'Are you sure?' copy + the standard `.btn-confirm OK`
+ `.btn-cancel NVM` button pair. The Brief banner above carries
the quota-specific info, so the portal stays text-free of
conditional wording."""
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_guard_portal.active"
)
)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
self.assertIn("sure", portal.text.lower())
portal.find_element(By.CSS_SELECTOR, ".guard-yes")
portal.find_element(By.CSS_SELECTOR, ".guard-no")
# ── Test 5 ───────────────────────────────────────────────────────────────
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
"""Clicking the portal's OK (`.guard-yes`) 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, "#id_guard_portal.active .guard-yes"
)
)
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(
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"

View File

@@ -503,3 +503,11 @@ body.page-gameboard {
opacity: 0.5;
cursor: default;
}
// ── Iter 4b: Brief banner + DEL guard portal ─────────────────────────────────
// Both reuse shared chrome: the Brief is `.note-banner` from note.js
// (portaled atop h2 w. Gaussian glass); the DEL guard is `#id_guard_portal`
// from base.html (the same one the room gear-menu DEL uses, positioned
// above the anchor button w. Gaussian glass + no backdrop). The picker IIFE
// invokes it via `window.showGuard(delBtn, "Are you sure?", confirmFn,
// null, {yesLabel: "DEL"})`. No my-sea-specific SCSS needed.

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 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>
@@ -153,17 +157,17 @@
aria-haspopup="listbox"
aria-labelledby="id_sea_spread_label"
tabindex="0">
<span class="sea-select-current">Situation, Action, Outcome</span>
<span class="sea-select-current">{% if default_spread == 'past-present-future' %}Past, Present, Future{% elif default_spread == 'mind-body-spirit' %}Mind, Body, Spirit{% elif default_spread == 'desire-obstacle-solution' %}Desire, Obstacle, Solution{% elif default_spread == 'waite-smith' %}Celtic Cross, Waite-Smith{% elif default_spread == 'escape-velocity' %}Celtic Cross, Escape Velocity{% else %}Situation, Action, Outcome{% endif %}</span>
<span class="sea-select-arrow" aria-hidden="true"></span>
<ul class="sea-select-list" role="listbox">
<li role="presentation" class="sea-select-divider">3-card spreads</li>
<li role="option" data-value="past-present-future" aria-selected="false">Past, Present, Future</li>
<li role="option" data-value="situation-action-outcome" aria-selected="true">Situation, Action, Outcome</li>
<li role="option" data-value="mind-body-spirit" aria-selected="false">Mind, Body, Spirit</li>
<li role="option" data-value="desire-obstacle-solution" aria-selected="false">Desire, Obstacle, Solution</li>
<li role="option" data-value="past-present-future" aria-selected="{% if default_spread == 'past-present-future' %}true{% else %}false{% endif %}">Past, Present, Future</li>
<li role="option" data-value="situation-action-outcome" aria-selected="{% if default_spread == 'situation-action-outcome' %}true{% else %}false{% endif %}">Situation, Action, Outcome</li>
<li role="option" data-value="mind-body-spirit" aria-selected="{% if default_spread == 'mind-body-spirit' %}true{% else %}false{% endif %}">Mind, Body, Spirit</li>
<li role="option" data-value="desire-obstacle-solution" aria-selected="{% if default_spread == 'desire-obstacle-solution' %}true{% else %}false{% endif %}">Desire, Obstacle, Solution</li>
<li role="presentation" class="sea-select-divider">6-card spreads</li>
<li role="option" data-value="waite-smith" aria-selected="false">Celtic Cross, Waite-Smith</li>
<li role="option" data-value="escape-velocity" aria-selected="false">Celtic Cross, Escape Velocity</li>
<li role="option" data-value="waite-smith" aria-selected="{% if default_spread == 'waite-smith' %}true{% else %}false{% endif %}">Celtic Cross, Waite-Smith</li>
<li role="option" data-value="escape-velocity" aria-selected="{% if default_spread == 'escape-velocity' %}true{% else %}false{% endif %}">Celtic Cross, Escape Velocity</li>
</ul>
</div>
</div>
@@ -200,6 +204,13 @@
{# thumbnail. #}
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>
{# Iter 4b — DEL guard reuses the shared `#id_guard_portal` #}
{# from base.html (the same one the room's gear-menu DEL btn #}
{# uses). Gaussian-glass tooltip positioned above the DEL btn,#}
{# no backdrop. The picker IIFE below invokes it via #}
{# `window.showGuard(delBtn, "Are you sure?", confirmFn, null,#}
{# {yesLabel: "DEL"})` when DEL is clicked post-lock. #}
{# 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 +396,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,16 +462,148 @@
// 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 invokes the shared `#id_guard_portal`
// from base.html via `window.showGuard`, w. a
// "DEL" YES-label override (the room's gear-
// menu DEL flow uses the same portal). On YES
// we POST to /gameboard/my-sea/delete then
// navigate back to the page (server returns
// 204; we redirect manually to land on the
// FREE DRAW landing).
if (delBtn) {
delBtn.addEventListener('click', function () {
if (_locked) return;
_resetHand();
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (!_locked) {
_resetHand();
return;
}
if (!window.showGuard) return;
// Trigger btn (DEL, `.btn-danger`) opens the shared
// guard portal; the portal's confirm button is the
// standard `.btn-confirm` "OK" + `.btn-cancel` "NVM"
// pair — matches the room gear-menu DEL flow exactly.
window.showGuard(
delBtn,
'Are you sure?',
function () {
fetch('{% url "my_sea_delete" %}', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': _csrf()},
}).then(function (r) {
if (r.ok) window.location.reload();
});
}
);
});
}
if (lockBtn) {
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);
_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
// (e.g., "Thu, May 21 @ 2:41 AM").
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;
}
window._showFreeDrawLockedBrief = function (iso) {
// Standard Brief banner — portaled atop the h2 w.
// Gaussian-glass bg (see [[note.js]] showBanner). The
// next-free-draw moment is passed as an ISO string +
// re-used as `created_at` so note.js's `<time
// class="note-banner__timestamp">` slot renders the
// datetime instead of "Invalid Date" (which it does
// for empty/invalid input). The `line_text` carries
// only the contextual prose now — the dedicated slot
// owns the timestamp display.
if (!window.Brief || !Brief.showBanner) return;
Brief.showBanner({
title: 'Free draw locked',
line_text:
'Look!&mdash;your free draw is locked in. ' +
'Next free draw available at:',
post_url: '{% url "gameboard" %}',
created_at: iso,
kind: 'NUDGE',
});
var banner = document.querySelector('.note-banner');
if (banner) {
banner.classList.add('my-sea-locked-banner');
// note.js renders the timestamp as `toLocaleDateString`
// (e.g., "May 20, 2026") — short-form, no time. Our
// use case wants the full `D, M j @ g:i A` shape
// (e.g., "Wed, May 20 @ 11:57 PM") so the user sees
// both the date AND the precise unlock hour. Overwrite
// the rendered text in-place (leaves the `datetime=`
// attribute intact for accessibility tooling).
var ts = banner.querySelector('.note-banner__timestamp');
if (ts && iso) ts.textContent = _formatTimestamp(iso);
// No FYI on this Brief — it's an informational nudge
// (locked draw status), not a navigation target. The
// NVM button + timestamp slot carry all the affordance
// the user needs.
var fyi = banner.querySelector('.note-banner__fyi');
if (fyi) fyi.remove();
}
};
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) {
window._showFreeDrawLockedBrief(body.next_free_draw_at);
}
});
}
@@ -475,10 +624,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
@@ -494,6 +652,18 @@
if (_initialOpt && hidden.value !== _initialOpt.dataset.value) {
hidden.value = _initialOpt.dataset.value;
}
// Mirror the hidden value onto the cross's `data-spread` +
// re-run syncLabels. Idempotent when server-rendered state
// is internally consistent; corrective if a prior page
// state (Firefox bfcache restoring a Celtic-Cross DOM,
// session left mid-draw etc.) left a stale `data-spread`
// that SCSS-hides the wrong subset of cells. Without this,
// a post-DEL reload could land on the picker w. data-
// spread="waite-smith" but SAO labels + SAO hidden value
// → all 6 cells visible, sometimes unlabeled (the user-
// observed bug, 2026-05-20).
cross.setAttribute('data-spread', hidden.value);
syncLabels(hidden.value);
// Exposed for iter 4b / future surfaces.
window._mySeaDrawOrder = DRAW_ORDER;
@@ -555,6 +725,22 @@
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
{# any other Briefs on the page. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
{% if active_draw %}
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
{# the on-LOCK-success path inside the picker IIFE calls the #}
{# same `window._showFreeDrawLockedBrief` so a freshly-locked #}
{# hand gets the identical UX without a page reload. Pass an #}
{# ISO timestamp (`|date:'c'`) so note.js's `<time>` slot #}
{# parses cleanly instead of rendering "Invalid Date". #}
<script>
document.addEventListener('DOMContentLoaded', function () {
if (window._showFreeDrawLockedBrief) {
window._showFreeDrawLockedBrief("{{ next_free_draw_at|date:'c' }}");
}
});
</script>
{% endif %}
{% if show_backup_intro_banner %}
<script>
document.addEventListener('DOMContentLoaded', function () {

View File

@@ -141,6 +141,10 @@
_cb = callback;
_onDismiss = onDismiss || null;
portal.querySelector('.guard-message').innerHTML = message;
// Optional override for the YES-button label (e.g., "DEL" for
// a destructive-named action). Resets to "OK" inside dismiss/
// doConfirm so the next show() starts from the default.
portal.querySelector('.guard-yes').textContent = options.yesLabel || 'OK';
portal.classList.add('active');
var rect = anchor.getBoundingClientRect();
var pw = portal.offsetWidth;
@@ -165,6 +169,7 @@
if (!portal) return;
var od = _onDismiss;
portal.classList.remove('active');
portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null;
_onDismiss = null;
if (od) od();
@@ -173,6 +178,7 @@
function doConfirm() {
var cb = _cb;
portal.classList.remove('active');
portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null;
_onDismiss = null;
if (cb) cb();