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):
|
||||
return {
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"arcana": c.arcana,
|
||||
"suit": c.suit,
|
||||
"number": c.number,
|
||||
"corner_rank": c.corner_rank,
|
||||
"suit_icon": c.suit_icon,
|
||||
"name_group": c.name_group,
|
||||
"name_title": c.name_title,
|
||||
"levity_qualifier": c.levity_qualifier,
|
||||
"gravity_qualifier": c.gravity_qualifier,
|
||||
"reversal_qualifier": c.reversal_qualifier,
|
||||
# Polarity-split full-title overrides — required for Major
|
||||
# Arcana (Earthman trumps 19-21 + cards 48-49) to render
|
||||
# their per-polarity emanation/reversal names on the stage
|
||||
# card. Without these StageCard.populateCard falls back to
|
||||
# the plain `name_title` w. no qualifier. Mirrors the
|
||||
# gameroom `epic.views.sea_deck` JSON shape exactly.
|
||||
"levity_emanation": c.levity_emanation,
|
||||
"gravity_emanation": c.gravity_emanation,
|
||||
"levity_reversal": c.levity_reversal,
|
||||
"gravity_reversal": c.gravity_reversal,
|
||||
"italic_word": c.italic_word,
|
||||
"keywords_upright": c.keywords_upright,
|
||||
"keywords_reversed": c.keywords_reversed,
|
||||
"energies": c.energies,
|
||||
"operations": c.operations,
|
||||
"reversed": random.random() < reversal_prob,
|
||||
}
|
||||
|
||||
reversal_prob = stack_reversal_probability(user)
|
||||
return {
|
||||
"levity": [_card_dict(c) for c in available[:mid]],
|
||||
"gravity": [_card_dict(c) for c in available[mid:]],
|
||||
"levity": [card_dict(c, reversal_prob) for c in available[:mid]],
|
||||
"gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user