diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py index 1f9c279..b9cf82d 100644 --- a/src/apps/epic/utils.py +++ b/src/apps/epic/utils.py @@ -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 diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 04fceb4..ee93056 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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 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:]], }) diff --git a/src/apps/gameboard/migrations/0001_initial.py b/src/apps/gameboard/migrations/0001_initial.py new file mode 100644 index 0000000..76d4b12 --- /dev/null +++ b/src/apps/gameboard/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/src/apps/gameboard/migrations/__init__.py b/src/apps/gameboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 71a8362..9636388 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -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() diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 4726b6a..e6e4f34 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -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, '