diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 0befecb..73d3ccb 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -966,5 +966,28 @@ class BillboardAppletMySignTest(TestCase): response = self.client.get("/billboard/") self.assertContains(response, "my-sign-applet-card") self.assertContains(response, f'data-card-id="{target.id}"') - # significator_reversed = True → card carries stage-card--reversed class - self.assertContains(response, "stage-card--reversed") + # significator_reversed = True ↔ polarity=levity (per convention). + # Saved sigs are POLARITY-only — the orientation (SPIN) axis is not + # persisted, so the applet card renders upright with the levity + # polarity class, NOT rotated via `stage-card--reversed`. + self.assertContains(response, "my-sign-applet-card--levity") + self.assertNotContains(response, "stage-card--reversed") + # Polarity qualifier renders alongside the title (middle court → + # "Elevated" for levity, "Graven" for gravity). + self.assertContains(response, "fan-card-qualifier") + if target.levity_qualifier: + self.assertContains(response, target.levity_qualifier) + # Always the emanation face — keywords_upright + "Emanation" label. + self.assertContains(response, "Emanation") + self.assertNotContains(response, ">Reversal<") + + def test_my_sign_applet_renders_gravity_qualifier_when_not_reversed(self): + from apps.epic.models import personal_sig_cards + target = personal_sig_cards(self.user)[0] + self.user.significator = target + self.user.significator_reversed = False + self.user.save(update_fields=["significator", "significator_reversed"]) + response = self.client.get("/billboard/") + self.assertContains(response, "my-sign-applet-card--gravity") + if target.gravity_qualifier: + self.assertContains(response, target.gravity_qualifier) diff --git a/src/apps/epic/migrations/0009_reversal_drops_qualifier.py b/src/apps/epic/migrations/0009_reversal_drops_qualifier.py new file mode 100644 index 0000000..1b3d81d --- /dev/null +++ b/src/apps/epic/migrations/0009_reversal_drops_qualifier.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-23 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0008_blades_reversal_fickle'), + ] + + operations = [ + migrations.AddField( + model_name='tarotcard', + name='reversal_drops_qualifier', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py b/src/apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py new file mode 100644 index 0000000..914e741 --- /dev/null +++ b/src/apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py @@ -0,0 +1,37 @@ +"""Cards 16-18 (Realms — Disco Inferno / Torre Terrestre / Fantasia Celestia) +have a reversal NAME swap (`reversal_qualifier` field carrying "Shame" / +"Guilt" / "Anxiety") but per user-spec 2026-05-23 the reversal face renders +the name ALONE, with NO polarity qualifier appended. Set +`reversal_drops_qualifier=True` so `TarotCard.applet_face()` knows to drop +the polarity qualifier on the reversal face. See [[feedback-reversal- +qualifier-dual-role]] for the broader Pattern B vs Pattern B' distinction. +""" +from django.db import migrations + + +REVERSAL_DROPS_QUALIFIER_NUMBERS = [16, 17, 18] + + +def set_flag(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + TarotCard.objects.filter( + arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS, + ).update(reversal_drops_qualifier=True) + + +def clear_flag(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + TarotCard.objects.filter( + arcana="MAJOR", number__in=REVERSAL_DROPS_QUALIFIER_NUMBERS, + ).update(reversal_drops_qualifier=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0009_reversal_drops_qualifier"), + ] + + operations = [ + migrations.RunPython(set_flag, reverse_code=clear_flag), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index c813aad..b31e40a 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -279,7 +279,8 @@ class TarotCard(models.Model): slug = models.SlugField(max_length=120) correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent group = models.CharField(max_length=100, blank=True) # Earthman major grouping - reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # reversal-axis qualifier (e.g. "Nervous"); polarity-shared; blank = falls back to current polarity's qualifier + reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # polysemous (cf [[feedback-reversal-qualifier-dual-role]]): on non-Majors w. no polarity qualifier it's the reversal-face qualifier (e.g. "Vacant"); on Majors w. polarity qualifiers it's the NAME-SWAP for the reversal face (e.g. "Patrilineage" for card 34). `applet_face()` routes on `arcana`. + reversal_drops_qualifier = models.BooleanField(default=False) # Pattern B' cards (16-18): reversal face shows the name swap ALONE, no qualifier. Pattern B (default False): polarity qualifier persists on the reversal face. levity_qualifier = models.CharField(max_length=100, blank=True, default='') gravity_qualifier = models.CharField(max_length=100, blank=True, default='') levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49) @@ -338,6 +339,75 @@ class TarotCard(models.Model): return self.gravity_reversal return self.reversal_qualifier or self.emanation_for(polarity) + def applet_face(self, polarity='gravity', reversed=False): + """Return the rendering payload for a card face in the My Sign / + My Sea applets — mirrors `populateCard` in `stage-card.js`. Four + patterns: + + - **Polarity-split FULL title** (cards 19-21, 48-49): single-line + title from `emanation_for` / `reversal_for`; qualifier blank. + - **Pattern B — Major w. polarity qualifier + reversal name-swap** + (cards 2-5, 10-15, 22-35, 41): `reversal_qualifier` carries the + REVERSAL-face NAME (e.g. "Patrilineage" for card 34). Polarity + qualifier persists across both faces. Renders: `,` + / `` on the reversal face. + - **Pattern B' — Major w. name-swap that DROPS qualifier on + reversal** (cards 16-18 — Realms): same as Pattern B but the + reversal face renders only the name (e.g. "Shame"), no + qualifier. Marked via `reversal_drops_qualifier=True`. + - **Non-Major (middle / minor)**: qualifier ABOVE title; reversal + face uses `reversal_qualifier` as the QUALIFIER (NOT a name + swap) — e.g. "Queen of Crowns" stays as the title, "Vacant" + renders as the reversal qualifier. + + Returns a 3-key dict: + { + "title": str, # title (w. trailing comma for Major+qual) + "qualifier": str, # qualifier text (may be blank) + "qualifier_first": bool, # True ⇒ qualifier above title; False ⇒ below + } + """ + is_major = (self.arcana == self.MAJOR) + if reversed: + override = (self.levity_reversal if polarity == 'levity' + else self.gravity_reversal) + if override: + return {"title": override, "qualifier": "", "qualifier_first": False} + polarity_qualifier = ( + self.levity_qualifier if polarity == 'levity' + else self.gravity_qualifier + ) + # Pattern B / B' — Major w. both polarity qualifier + reversal + # name-swap. `reversal_qualifier` is the SWAPPED NAME (not a + # qualifier) for these Majors. See `reversal_qualifier` field + # docstring + [[feedback-reversal-qualifier-dual-role]]. + if is_major and self.reversal_qualifier and polarity_qualifier: + if self.reversal_drops_qualifier: + # Pattern B' (16-18): single-line reversal name. + return {"title": self.reversal_qualifier, + "qualifier": "", "qualifier_first": False} + # Pattern B (2-5, 10-15, 22-35, 41): swapped name + polarity + # qualifier carried across both faces. + return {"title": self.reversal_qualifier + ",", + "qualifier": polarity_qualifier, + "qualifier_first": False} + # Non-Major OR Major-without-polarity-qualifier: reversal_ + # qualifier is the qualifier (Pattern A / fallback). + qualifier = self.reversal_qualifier or polarity_qualifier + else: + override = (self.levity_emanation if polarity == 'levity' + else self.gravity_emanation) + if override: + return {"title": override, "qualifier": "", "qualifier_first": False} + qualifier = (self.levity_qualifier if polarity == 'levity' + else self.gravity_qualifier) + + title = self.name_title + if is_major and qualifier: + return {"title": title + ",", "qualifier": qualifier, + "qualifier_first": False} + return {"title": title, "qualifier": qualifier, "qualifier_first": True} + @property def name_group(self): """Returns 'Group N:' prefix if the name contains ': ', else ''.""" diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index 682928d..c530299 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -31,6 +31,12 @@ var StageCard = (function () { levity_qualifier: el.dataset.levityQualifier || '', gravity_qualifier: el.dataset.gravityQualifier || '', reversal_qualifier: el.dataset.reversalQualifier || '', + // Pattern B' marker — `data-reversal-drops-qualifier="true"` + // means the reversal face renders the name swap (in + // `reversal_qualifier`) WITHOUT a trailing polarity qualifier. + // Set on cards 16-18 (Realms) via the Earthman deck seed. + reversal_drops_qualifier: + el.dataset.reversalDropsQualifier === 'true', // Polarity-split title overrides — non-blank for cards 48-49 only, // where each polarity (and within each polarity, each axis state) // has a fully distinct title rather than a shared name + qualifier. @@ -151,6 +157,15 @@ var StageCard = (function () { // assign classes per-arcana here so each branch lands in the right slot: // Major / polarity-split — title on top → .name carried by DOM-second // Non-major — qualifier on top → .qualifier carried by DOM-second + // + // Pattern B / B' Majors (cards 2-5, 10-15, 16-18, 22-35, 41): `card. + // reversal_qualifier` is the NAME SWAP for the reversal face (not a + // qualifier). Polarity qualifier persists across both faces — unless + // `reversal_drops_qualifier=true` (Realms — cards 16-18), in which + // case the reversal face renders the name swap alone. See + // `TarotCard.applet_face` docstring + [[feedback-reversal-qualifier- + // dual-role]]. + var hasNameSwap = isMajor && reversalQualifier && qualifier; var slots = stageCard.querySelectorAll('.fan-card-face-reversal > p'); if (slots.length === 2) { var bottomEl = slots[0]; // DOM-first → visually bottom after spin @@ -166,8 +181,23 @@ var StageCard = (function () { _setTitle(topEl, reversalOverride, card); bottomEl.className = 'fan-card-reversal-qualifier'; bottomEl.textContent = ''; + } else if (hasNameSwap) { + // Pattern B / B' — swapped name on TOP. + topEl.className = 'fan-card-reversal-name'; + if (card.reversal_drops_qualifier) { + // Pattern B' (Realms 16-18): single-line swap name, no qualifier. + _setTitle(topEl, reversalQualifier, card); + bottomEl.className = 'fan-card-reversal-qualifier'; + bottomEl.textContent = ''; + } else { + // Pattern B (2-5, 10-15, 22-35, 41): swap name w. comma + polarity qualifier below. + _setTitle(topEl, reversalQualifier + ',', card); + bottomEl.className = 'fan-card-reversal-qualifier'; + bottomEl.textContent = qualifier; + } } else if (isMajor) { - // Major: title-with-comma on TOP, qualifier on BOTTOM. + // Major w. qualifier (no name swap — cards 6-9 implicit virtues): + // title-with-comma on TOP, polarity qualifier on BOTTOM. topEl.className = 'fan-card-reversal-name'; _setTitle(topEl, title + ',', card); bottomEl.className = 'fan-card-reversal-qualifier'; diff --git a/src/apps/epic/tests/unit/test_models.py b/src/apps/epic/tests/unit/test_models.py index 85331d7..ff7d20b 100644 --- a/src/apps/epic/tests/unit/test_models.py +++ b/src/apps/epic/tests/unit/test_models.py @@ -135,6 +135,164 @@ class TarotCardReversalForTest(SimpleTestCase): self.assertEqual(c.reversal_for('levity'), 'The Nomad') +class TarotCardAppletFaceTest(SimpleTestCase): + """TarotCard.applet_face — face rendering payload for the My Sign / My + Sea applets. Mirrors `populateCard` in `stage-card.js:135-144`.""" + + def test_major_with_qualifier_renders_title_with_comma_qualifier_below(self): + # Trump 9 — Major Arcana w. lev/grav qualifiers ("Sublimating" / + # "Sedimentary"). The applet face should match the page's stage + # card layout: "Title," on top, qualifier below. + c = TarotCard() + c.name = "Erasing Personal History" + c.arcana = TarotCard.MAJOR + c.levity_qualifier = "Sublimating" + c.gravity_qualifier = "Sedimentary" + face = c.applet_face("levity", reversed=False) + self.assertEqual(face["title"], "Erasing Personal History,") + self.assertEqual(face["qualifier"], "Sublimating") + self.assertFalse(face["qualifier_first"]) + + def test_major_with_qualifier_gravity_polarity(self): + c = TarotCard() + c.name = "Erasing Personal History" + c.arcana = TarotCard.MAJOR + c.levity_qualifier = "Sublimating" + c.gravity_qualifier = "Sedimentary" + face = c.applet_face("gravity", reversed=False) + self.assertEqual(face["title"], "Erasing Personal History,") + self.assertEqual(face["qualifier"], "Sedimentary") + self.assertFalse(face["qualifier_first"]) + + def test_non_major_renders_qualifier_above_title(self): + # Queen of Crowns — Middle court. Qualifier ABOVE title (no comma). + c = TarotCard() + c.name = "Queen of Crowns" + c.arcana = TarotCard.MIDDLE + c.levity_qualifier = "Elevated" + c.gravity_qualifier = "Graven" + face = c.applet_face("levity", reversed=False) + self.assertEqual(face["title"], "Queen of Crowns") + self.assertEqual(face["qualifier"], "Elevated") + self.assertTrue(face["qualifier_first"]) + + def test_polarity_split_emanation_single_line_no_qualifier(self): + # Card 48-49 — polarity-split title, qualifier blank. + c = TarotCard() + c.name = "Group 11: The Awakened" + c.arcana = TarotCard.MAJOR + c.levity_emanation = "The Effulgent Mould of Man" + c.gravity_emanation = "The Tellurian Mould of Man" + face = c.applet_face("levity", reversed=False) + self.assertEqual(face["title"], "The Effulgent Mould of Man") + self.assertEqual(face["qualifier"], "") + self.assertFalse(face["qualifier_first"]) + + def test_reversed_polarity_split_uses_reversal_title(self): + c = TarotCard() + c.name = "Group 11: The Awakened" + c.arcana = TarotCard.MAJOR + c.levity_reversal = "The Reflected Mould of Man" + c.gravity_reversal = "The Obscured Mould of Man" + face = c.applet_face("levity", reversed=True) + self.assertEqual(face["title"], "The Reflected Mould of Man") + self.assertEqual(face["qualifier"], "") + + def test_non_major_reversed_falls_back_to_polarity_qualifier(self): + # Two of Crowns reversed (MINOR), no explicit reversal_qualifier + # → uses polarity qualifier as fallback. Qualifier above title. + c = TarotCard() + c.name = "Two of Crowns" + c.arcana = TarotCard.MINOR + c.levity_qualifier = "Relieving" + c.gravity_qualifier = "Grieving" + face = c.applet_face("gravity", reversed=True) + self.assertEqual(face["title"], "Two of Crowns") + self.assertEqual(face["qualifier"], "Grieving") + self.assertTrue(face["qualifier_first"]) + + def test_pattern_b_major_reversal_swaps_name_keeps_polarity_qualifier(self): + """User-spec 2026-05-23: cards 2-5, 10-15, 22-35, 41 — Major Arcana + w. BOTH polarity qualifiers AND a `reversal_qualifier` should treat + `reversal_qualifier` as a NAME SWAP for the reversal face. The + polarity qualifier persists across both faces. Example: card 34 + Animal Powers / Patrilineage.""" + c = TarotCard() + c.name = "Animal Powers" + c.arcana = TarotCard.MAJOR + c.levity_qualifier = "Centrifugal" + c.gravity_qualifier = "Centripetal" + c.reversal_qualifier = "Patrilineage" # name swap, NOT a qualifier + # Levity emanation — upright + polarity qualifier + face = c.applet_face("levity", reversed=False) + self.assertEqual(face["title"], "Animal Powers,") + self.assertEqual(face["qualifier"], "Centrifugal") + # Levity reversal — name swap + polarity qualifier persists + face = c.applet_face("levity", reversed=True) + self.assertEqual(face["title"], "Patrilineage,") + self.assertEqual(face["qualifier"], "Centrifugal") + # Gravity emanation + face = c.applet_face("gravity", reversed=False) + self.assertEqual(face["title"], "Animal Powers,") + self.assertEqual(face["qualifier"], "Centripetal") + # Gravity reversal — name swap + polarity qualifier + face = c.applet_face("gravity", reversed=True) + self.assertEqual(face["title"], "Patrilineage,") + self.assertEqual(face["qualifier"], "Centripetal") + + def test_pattern_b_prime_major_reversal_drops_qualifier(self): + """User-spec 2026-05-23: cards 16-18 (Realms) — same Pattern B name + swap, but the reversal face renders the name ALONE w. no qualifier. + Marked via `reversal_drops_qualifier=True`.""" + c = TarotCard() + c.name = "Disco Inferno" + c.arcana = TarotCard.MAJOR + c.levity_qualifier = "Deasil" + c.gravity_qualifier = "Widdershins" + c.reversal_qualifier = "Shame" + c.reversal_drops_qualifier = True + # Upright unchanged (polarity qualifier on the upright face) + face = c.applet_face("levity", reversed=False) + self.assertEqual(face["title"], "Disco Inferno,") + self.assertEqual(face["qualifier"], "Deasil") + # Reversal: name swap ALONE, no qualifier + face = c.applet_face("levity", reversed=True) + self.assertEqual(face["title"], "Shame") + self.assertEqual(face["qualifier"], "") + face = c.applet_face("gravity", reversed=True) + self.assertEqual(face["title"], "Shame") + self.assertEqual(face["qualifier"], "") + + def test_non_major_with_reversal_qualifier_still_renders_as_qualifier(self): + """Regression pin — Queen of Crowns (middle court) uses `reversal_ + qualifier` as the QUALIFIER on the reversal face (not a name swap). + Pattern B / B' must NOT bleed into non-Majors.""" + c = TarotCard() + c.name = "Queen of Crowns" + c.arcana = TarotCard.MIDDLE + c.levity_qualifier = "Elevated" + c.gravity_qualifier = "Graven" + c.reversal_qualifier = "Vacant" + face = c.applet_face("levity", reversed=True) + # Title stays as the original name; reversal_qualifier is the qualifier + self.assertEqual(face["title"], "Queen of Crowns") + self.assertEqual(face["qualifier"], "Vacant") + self.assertTrue(face["qualifier_first"]) # non-Major: qualifier above + + def test_major_without_qualifier_renders_qualifier_first_for_blank(self): + # Major 0 (Nomad) — no levity/gravity qualifier. Qualifier-first + # branch is taken (since `qualifier_first` is the default when no + # Major+qualifier check passes); qualifier text is blank so the + # `:empty` CSS hides the slot. + c = TarotCard() + c.name = "Group 3: The Nomad" + c.arcana = TarotCard.MAJOR + face = c.applet_face("gravity", reversed=False) + self.assertEqual(face["title"], "The Nomad") + self.assertEqual(face["qualifier"], "") + self.assertTrue(face["qualifier_first"]) + + class TarotCardNameSplitTest(SimpleTestCase): """TarotCard.name_group / name_title — colon-split parsing.""" diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py index b9cf82d..db7b324 100644 --- a/src/apps/epic/utils.py +++ b/src/apps/epic/utils.py @@ -52,6 +52,10 @@ def card_dict(card, reversal_prob=STACK_REVERSAL_PROBABILITY): 'levity_qualifier': card.levity_qualifier, 'gravity_qualifier': card.gravity_qualifier, 'reversal_qualifier': card.reversal_qualifier, + # Pattern B / B' marker — cards 16-18 set this True so the reversal + # face renders the name swap WITHOUT a polarity qualifier appended. + # See `TarotCard.applet_face` docstring for the full pattern table. + 'reversal_drops_qualifier': card.reversal_drops_qualifier, # Polarity-split full-title overrides (cards 48-49 + trumps 19-21) 'levity_emanation': card.levity_emanation, 'gravity_emanation': card.gravity_emanation, diff --git a/src/apps/gameboard/migrations/0003_myseadraw_paid_through_at.py b/src/apps/gameboard/migrations/0003_myseadraw_paid_through_at.py new file mode 100644 index 0000000..aa77b64 --- /dev/null +++ b/src/apps/gameboard/migrations/0003_myseadraw_paid_through_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-23 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gameboard', '0002_myseadraw_deposit_reserved_at_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='myseadraw', + name='paid_through_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 30b4de4..548ebf5 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -68,6 +68,7 @@ def latest_draw_slots(user): "card": , # None if not yet drawn "reversed": bool, # False if not yet drawn "polarity": "gravity", # "" if not yet drawn + "face": dict | None, # applet_face() payload — see TarotCard } Empty list = no active draw OR active draw w. empty hand. """ @@ -89,12 +90,17 @@ def latest_draw_slots(user): slots = [] for pos in order: entry = by_position.get(pos) + card = cards_by_id.get(entry["card_id"]) if entry else None + reversed_flag = entry.get("reversed", False) if entry else False + polarity = entry.get("polarity", "gravity") if entry else "" slots.append({ "position": pos, "label": labels.get(pos, ""), - "card": cards_by_id.get(entry["card_id"]) if entry else None, - "reversed": entry.get("reversed", False) if entry else False, - "polarity": entry.get("polarity", "") if entry else "", + "card": card, + "reversed": reversed_flag, + "polarity": polarity, + "face": (card.applet_face(polarity or "gravity", reversed_flag) + if card else None), }) return slots @@ -144,6 +150,16 @@ class MySeaDraw(models.Model): # + `debit_my_sea_token` for the priority chain + per-type rules. deposit_token_id = models.IntegerField(null=True, blank=True) deposit_reserved_at = models.DateTimeField(null=True, blank=True) + # PAID DRAW credit marker — set when `my_sea_paid_draw` commits the + # deposited token. Stays sticky until the credit is consumed by the + # first card-draw (cleared in `my_sea_lock`) OR the row expires. + # Drives the landing-button state: a row w. `paid_through_at` set + + # `hand=[]` renders the PAID DRAW button (navigates to picker, no new + # token spent), so a user who pays + navigates away before drawing + # still sees their paid state preserved (user-reported bug 2026-05-23 + # — without this field, the row was deleted at commit time + the + # landing fell through to FREE DRAW on next page load). + paid_through_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] @@ -274,7 +290,12 @@ def active_draw_for(user): Lazy stale-row cleanup: every call prunes the user's >24h rows, so the DB doesn't accumulate one row per user per day. The user-spec 'auto-delete all draws after 24hrs, whether or not the user has - deleted them' (2026-05-20) lands here w. no scheduler required.""" + deleted them' (2026-05-20) lands here w. no scheduler required. + + Cooldown for FREE-DRAW-button rendering is separately tracked at the + User level (`User.last_free_draw_at`) — see [[feedback-cooldown- + anchored-to-free-draw]]. This function is purely about row TTL. + """ cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS) MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete() return MySeaDraw.objects.filter( diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js index 4554e3e..3eff346 100644 --- a/src/apps/gameboard/static/apps/gameboard/game-kit.js +++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js @@ -110,6 +110,11 @@ var GameKit = (function () { // and per-polarity qualifier rendering stay consistent with the data). StageCard.populateCard(cardEl, card, _polarity); cardEl.dataset.polarity = _polarity; + // Mirror polarity on `.tarot-fan-wrap` so the fan-stage-block can + // invert its bg per the sig convention (user-spec 2026-05-23: stat + // block always carries the opposite-polarity color of its adjacent + // card). See `_card-deck.scss:.tarot-fan-wrap[data-polarity]`. + if (fanWrap) fanWrap.dataset.polarity = _polarity; StageCard.populateKeywords(stageBlock, card.keywords_upright, card.keywords_reversed, { uprightSel: '#id_fan_stat_upright', @@ -156,6 +161,8 @@ var GameKit = (function () { var card = StageCard.fromDataset(active); StageCard.populateCard(active, card, _polarity); active.dataset.polarity = _polarity; + // Mirror onto the wrap so the stage block re-invertds in lockstep. + if (fanWrap) fanWrap.dataset.polarity = _polarity; }, 250); // Clear the in-flight flag at animation end. Using setTimeout (not // anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests. diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index bcfc853..eda61d5 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -195,6 +195,113 @@ class GameboardViewTest(TestCase): ["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"], ) + def test_my_sea_applet_renders_polarity_qualifier_per_slot(self): + """Each filled slot carries a `.fan-card-qualifier` whose text is + the polarity qualifier for the slot's polarity (upright) or the + reversal_qualifier (reversed). User-reported 2026-05-23: applet + was rendering only the title, no qualifier.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + # Pick a middle court card (Queen of Crowns) — has levity_qualifier + # "Elevated", gravity_qualifier "Graven", reversal_qualifier "Vacant". + queen_of_crowns = TarotCard.objects.filter( + arcana="MIDDLE", suit="CROWNS", number=13, + ).first() + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": queen_of_crowns.id, + "reversed": False, "polarity": "levity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + quals = parsed.cssselect( + "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier" + ) + self.assertEqual(len(quals), 1) + self.assertEqual(quals[0].text_content().strip(), "Elevated") + + def test_my_sea_applet_major_renders_title_comma_qualifier_below(self): + """Major Arcana w. a qualifier (trump 9 — 'Erasing Personal + History' + 'Sublimating') renders as 'Title,' / 'Qualifier' per + the page's stage card convention (`stage-card.js:141-143`). The + qualifier `

` lands AFTER the name `

` in DOM order.""" + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + trump_9 = TarotCard.objects.filter( + arcana="MAJOR", number=9, deck_variant__slug="earthman", + ).first() + self.assertIsNotNone(trump_9, + "seed migration 0007 should produce Earthman trump 9 " + "('Erasing Personal History') w. levity_qualifier='Sublimating'") + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": trump_9.id, + "reversed": False, "polarity": "levity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + face = parsed.cssselect( + "#id_applet_my_sea .my-sea-slot--filled .fan-card-face" + )[0] + # DOM order: name → qualifier (NOT qualifier → name). + children = [ + el for el in face + if el.tag == "p" + and any( + cls in (el.get("class") or "") + for cls in ("fan-card-name", "fan-card-qualifier") + ) + ] + self.assertEqual(len(children), 2) + self.assertIn("fan-card-name", children[0].get("class")) + self.assertIn("fan-card-qualifier", children[1].get("class")) + # Title carries a trailing comma; qualifier is "Sublimating" (levity). + self.assertTrue( + children[0].text_content().strip().endswith(","), + f"expected trailing comma on Major title, got " + f"{children[0].text_content()!r}", + ) + self.assertEqual(children[1].text_content().strip(), "Sublimating") + + def test_my_sea_applet_renders_reversal_qualifier_for_reversed_slot(self): + from apps.epic.models import personal_sig_cards, TarotCard + from apps.gameboard.models import MySeaDraw + sig_pile = personal_sig_cards(self.user) + self.user.significator = sig_pile[0] + self.user.save() + queen_of_crowns = TarotCard.objects.filter( + arcana="MIDDLE", suit="CROWNS", number=13, + ).first() + MySeaDraw.objects.create( + user=self.user, + spread="situation-action-outcome", + hand=[ + {"position": "lay", "card_id": queen_of_crowns.id, + "reversed": True, "polarity": "gravity"}, + ], + significator_id=self.user.significator_id, + ) + response = self.client.get("/gameboard/") + parsed = lxml.html.fromstring(response.content) + quals = parsed.cssselect( + "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier" + ) + self.assertEqual(quals[0].text_content().strip(), "Vacant") + def test_gameboard_shows_game_kit(self): [_] = self.parsed.cssselect("#id_game_kit") @@ -1123,6 +1230,79 @@ class MySeaLockHandViewTest(TestCase): parsed = datetime.fromisoformat(body["next_free_draw_at"]) self.assertIsNotNone(parsed) + def test_lock_post_first_card_sets_user_last_free_draw_at(self): + # User-spec 2026-05-23: free-draw cooldown is anchored to User. + # last_free_draw_at, set on the first-card-of-cycle lock so the + # next FREE DRAW unlocks 24h later regardless of any intervening + # PAID DRAWs. Fresh user (no prior cooldown) → SET. + import json + before = timezone.now() + self.assertIsNone(self.user.last_free_draw_at, + "precondition: fresh user has no prior free draw") + response = self.client.post( + self.url, data=json.dumps(self._build_payload()), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertIsNotNone(self.user.last_free_draw_at) + self.assertGreaterEqual(self.user.last_free_draw_at, before) + + def test_lock_post_during_cooldown_does_not_reset_last_free_draw_at(self): + # User is mid-cooldown (last_free_draw_at set to 6h ago). A + # subsequent /lock POST (e.g. a paid draw committing its first + # card) must NOT bump last_free_draw_at — the cooldown stays + # anchored to the original FREE DRAW per user-spec 2026-05-23. + import json + from datetime import timedelta + from apps.gameboard.models import MySeaDraw + original_anchor = timezone.now() - timedelta(hours=6) + self.user.last_free_draw_at = original_anchor + self.user.save(update_fields=["last_free_draw_at"]) + # Seed an existing row in a paid-through state (no hand yet). + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + paid_through_at=timezone.now(), + ) + # Now lock the first paid card. + self.client.post( + self.url, data=json.dumps(self._build_payload(hand=[{ + "position": "lay", "card_id": self.target.id, + "reversed": False, "polarity": "gravity", + }])), + content_type="application/json", + ) + self.user.refresh_from_db() + # last_free_draw_at unchanged — within 1s of original anchor. + delta = abs((self.user.last_free_draw_at - original_anchor).total_seconds()) + self.assertLess(delta, 1.0) + + def test_lock_post_first_paid_card_consumes_paid_through_credit(self): + # User-spec 2026-05-23: paid_through credit is one-shot. The + # first card drawn after PAID DRAW commit clears `paid_through_ + # at` so the next redraw requires a fresh gatekeeper deposit. + import json + from apps.gameboard.models import MySeaDraw + self.user.last_free_draw_at = timezone.now() - timezone.timedelta(hours=6) + self.user.save(update_fields=["last_free_draw_at"]) + row = MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + paid_through_at=timezone.now(), + ) + self.client.post( + self.url, data=json.dumps(self._build_payload(hand=[{ + "position": "lay", "card_id": self.target.id, + "reversed": False, "polarity": "gravity", + }])), + content_type="application/json", + ) + row.refresh_from_db() + self.assertIsNone(row.paid_through_at, + "first card of paid session must consume the paid_through credit") + self.assertEqual(len(row.hand), 1) + def test_lock_post_within_quota_upserts_same_row(self): # Iter 4c — `/lock` is now an upsert (per-placement POST cadence). # Second POST w. same spread updates the existing row's hand @@ -1722,13 +1902,28 @@ class MySeaPaidDrawViewTest(TestCase): self.client.post(self.url) self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists()) - def test_paid_draw_deletes_active_draw_row(self): - # User-spec 2026-05-20: PAID DRAW commits the token + drops the row - # entirely so the user returns to a fresh "able-to-draw-now" state - # (instead of the buggy "row preserved → GATE VIEW loop" semantics). + def test_paid_draw_preserves_row_and_sets_paid_through_at(self): + # User-spec 2026-05-23 (replaces the 2026-05-20 "delete row" + # spec): PAID DRAW preserves the row + sets `paid_through_at` + # so the landing PAID DRAW button stays visible across navigation + # cycles. Without this, the user who pays but doesn't immediately + # draw cards sees the button revert to FREE DRAW on next page + # load (the reported regression). `deposit_token_id` / + # `deposit_reserved_at` clear (token spent, no longer reserved); + # `hand` clears (fresh start per user-confirmed semantics). from apps.gameboard.models import MySeaDraw + before = timezone.now() self.client.post(self.url) - self.assertFalse(MySeaDraw.objects.filter(pk=self.draw.pk).exists()) + self.draw.refresh_from_db() + self.assertTrue(MySeaDraw.objects.filter(pk=self.draw.pk).exists(), + "PAID DRAW must preserve the row (was previously deleted)") + self.assertIsNone(self.draw.deposit_token_id) + self.assertIsNone(self.draw.deposit_reserved_at) + self.assertIsNotNone(self.draw.paid_through_at, + "PAID DRAW must stamp paid_through_at on commit") + self.assertGreaterEqual(self.draw.paid_through_at, before) + self.assertEqual(self.draw.hand, [], + "PAID DRAW must clear the hand (fresh paid session)") def test_paid_draw_redirects_to_my_sea_with_phase_picker(self): # User-spec 2026-05-20: drop the user directly into the picker @@ -1772,12 +1967,203 @@ class MySeaPaidDrawViewTest(TestCase): self.assertEqual(response.status_code, 302) +class MySeaCooldownAnchoredToFreeDrawTest(TestCase): + """User-spec 2026-05-23: the 24h free-draw cooldown is anchored to the + user's last TOKENLESS first-card-draw (`User.last_free_draw_at`), NOT + to any subsequent paid draws. A paid draw in the middle of the cycle + must NOT push the cooldown forward — the next FREE DRAW unlocks at + free-draw + 24h regardless of any interim paid activity. + + Also pins the "sticky PAID DRAW button" UX: after PAID DRAW commits, + the row carries a `paid_through_at` credit until the first card of + the paid session lands. During that window, any navigation back to + /gameboard/my-sea/ keeps the landing button labelled PAID DRAW (it + used to revert to FREE DRAW because the row was deleted at commit + time — the user-reported bug).""" + + def setUp(self): + from apps.epic.models import personal_sig_cards + from apps.lyric.models import Token + self.user = User.objects.create(email="anchor@test.io") + self.user.tokens.all().delete() + 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"]) + # FREE token for the paid-draw step. + self.free_tok = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=30), + ) + + def _seed_used_free_draw(self, when=None): + """Simulate a completed FREE DRAW + DEL: row exists w. hand=[], + no deposit, `User.last_free_draw_at` anchored at `when`.""" + from apps.gameboard.models import MySeaDraw + when = when or timezone.now() + self.user.last_free_draw_at = when + self.user.save(update_fields=["last_free_draw_at"]) + return MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + created_at=when, + ) + + def test_paid_draw_does_not_reset_user_last_free_draw_at(self): + original_anchor = timezone.now() - timedelta(hours=6) + draw = self._seed_used_free_draw(when=original_anchor) + # Deposit + commit a token via the PAID DRAW endpoint. + draw.deposit_token_id = self.free_tok.pk + draw.deposit_reserved_at = timezone.now() + draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) + self.client.post(reverse("my_sea_paid_draw")) + self.user.refresh_from_db() + # User.last_free_draw_at stays at the original anchor + # (within 1s tolerance for timestamp wobble). + delta = abs( + (self.user.last_free_draw_at - original_anchor).total_seconds() + ) + self.assertLess(delta, 1.0, + "PAID DRAW must NOT touch User.last_free_draw_at — the " + "cooldown stays anchored to the original FREE DRAW per " + "user-spec 2026-05-23") + + def test_brief_next_free_draw_at_uses_user_anchor_not_paid_row(self): + # The view passes `next_free_draw_at` to the template as ISO + # — the Brief script in my_sea.html surfaces this directly. + # Anchor: user's last_free_draw_at + 24h, NOT row.created_at + # + 24h (which after PAID DRAW would point 24h past the paid + # commit, not 24h past the free draw). + original_anchor = timezone.now() - timedelta(hours=6) + self._seed_used_free_draw(when=original_anchor) + # The active row's `created_at` matches the seed time (free + # draw moment). Now simulate a "row created LATER than anchor" + # state by bumping created_at forward — this is what would + # happen if the row were re-created at PAID DRAW time under + # the old buggy delete-on-commit semantics. + from apps.gameboard.models import MySeaDraw + row = MySeaDraw.objects.get(user=self.user) + row.created_at = timezone.now() # "PAID DRAW just created me" + row.save(update_fields=["created_at"]) + response = self.client.get(reverse("my_sea")) + # The user's next_free_draw_at = anchor + 24h, NOT row.created_at + # + 24h. Differs by ~6h; check that the rendered ISO matches the + # user-level anchor (truncated to date+hour for stability). + expected_user_iso = ( + original_anchor + timedelta(hours=24) + ).isoformat()[:13] # "YYYY-MM-DDTHH" — date + hour + self.assertIn(expected_user_iso, response.content.decode()) + + def test_paid_draw_commit_makes_landing_show_paid_draw_btn(self): + # End-to-end of the user-reported bug: deposit → PAID DRAW commit + # → navigate to /gameboard/my-sea/ → landing must show PAID DRAW + # (NOT FREE DRAW, NOT GATE VIEW). Pre-fix: row was deleted at + # commit time + landing fell through to FREE DRAW. + from apps.gameboard.models import MySeaDraw + draw = self._seed_used_free_draw() + draw.deposit_token_id = self.free_tok.pk + draw.deposit_reserved_at = timezone.now() + draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) + # Commit the paid draw. + self.client.post(reverse("my_sea_paid_draw")) + # Simulate user navigating away + back: re-render my_sea. + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'id="id_my_sea_paid_draw_btn"', + msg_prefix="post-PAID-DRAW navigation must keep PAID DRAW btn") + self.assertNotContains(response, 'id="id_draw_sea_btn"', + msg_prefix="FREE DRAW btn must NOT show after PAID DRAW commit") + self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"', + msg_prefix="GATE VIEW btn must NOT show while paid-through is set") + + def test_paid_draw_btn_post_with_paid_through_redirects_to_picker(self): + # After commit, the PAID DRAW button on the landing should + # route the user back to the picker (via ?phase=picker) without + # consuming another token. + from apps.gameboard.models import MySeaDraw + draw = self._seed_used_free_draw() + draw.paid_through_at = timezone.now() + draw.save(update_fields=["paid_through_at"]) + free_count_before = self.user.tokens.filter( + token_type=self.free_tok.token_type + ).count() + response = self.client.post(reverse("my_sea_paid_draw")) + self.assertEqual(response.status_code, 302) + self.assertIn("phase=picker", response["Location"]) + # No token consumed — the paid-through credit covers this. + self.assertEqual( + self.user.tokens.filter( + token_type=self.free_tok.token_type + ).count(), + free_count_before, + ) + + def test_first_card_after_paid_draw_consumes_paid_through_credit(self): + # User-spec 2026-05-23 follow-up: paid-through is one-shot. Once + # the user draws their first card of the paid session, the + # credit is consumed → next redraw needs a fresh deposit. + import json + from apps.gameboard.models import MySeaDraw + from apps.epic.models import TarotCard + draw = self._seed_used_free_draw() + draw.paid_through_at = timezone.now() + draw.save(update_fields=["paid_through_at"]) + card = TarotCard.objects.exclude(id=self.target.id).first() + self.client.post( + reverse("my_sea_lock"), + data=json.dumps({ + "spread": "situation-action-outcome", + "hand": [{ + "position": "lay", "card_id": card.id, + "reversed": False, "polarity": "gravity", + }], + }), + content_type="application/json", + ) + draw.refresh_from_db() + self.assertIsNone(draw.paid_through_at, + "first card of paid session consumes the paid-through credit") + + +class UserFreeDrawCooldownPropertyTest(TestCase): + """`User.free_draw_cooldown_active` + `User.next_free_draw_at` + helpers. The cooldown is sticky from `last_free_draw_at` (set on the + user's last tokenless first-card-draw) for FREE_DRAW_COOLDOWN_HOURS.""" + + def setUp(self): + self.user = User.objects.create(email="cooldown@test.io") + + def test_no_last_free_draw_at_returns_false(self): + self.assertIsNone(self.user.last_free_draw_at) + self.assertFalse(self.user.free_draw_cooldown_active) + self.assertIsNone(self.user.next_free_draw_at) + + def test_recent_last_free_draw_at_returns_true(self): + self.user.last_free_draw_at = timezone.now() - timedelta(hours=6) + self.user.save() + self.assertTrue(self.user.free_draw_cooldown_active) + + def test_old_last_free_draw_at_returns_false(self): + self.user.last_free_draw_at = timezone.now() - timedelta(hours=25) + self.user.save() + self.assertFalse(self.user.free_draw_cooldown_active) + + def test_next_free_draw_at_is_last_plus_24h(self): + anchor = timezone.now() - timedelta(hours=6) + self.user.last_free_draw_at = anchor + self.user.save() + self.assertEqual( + self.user.next_free_draw_at, + anchor + timedelta(hours=24), + ) + + class MySeaPhasePickerQueryParamTest(TestCase): - """Sprint 6 iter 6c — `?phase=picker` query param forces picker phase - when no active_draw row exists (the just-after-PAID-DRAW state). - Without the param, no-active-draw users default to the FREE DRAW - landing. With it, they drop straight into the picker so they can - start drawing immediately (the token they just spent earns this).""" + """`?phase=picker` query param forces picker phase when the user is + in a paid cycle (post-PAID-DRAW commit, hand still empty). Updated + 2026-05-23 — previously the param worked w. no active row (the old + "delete row at PAID DRAW" semantics). Under the new "preserve row + + set paid_through_at" semantics, the row is present + paid_through_at + is set; the picker shows via the param + the paid-through state.""" def setUp(self): from apps.epic.models import personal_sig_cards @@ -1792,15 +2178,24 @@ class MySeaPhasePickerQueryParamTest(TestCase): self.assertContains(response, 'data-phase="landing"') self.assertContains(response, 'id="id_draw_sea_btn"') - def test_phase_picker_param_forces_picker(self): + def test_phase_picker_param_forces_picker_when_paid_through(self): + # Simulate the just-after-PAID-DRAW state: row exists w. hand=[] + # + paid_through_at set. The ?phase=picker param drops the user + # into the picker rather than the landing. + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + paid_through_at=timezone.now(), + ) response = self.client.get(reverse("my_sea") + "?phase=picker") self.assertContains(response, 'data-phase="picker"') - # Picker IS rendered (no inline style="display:none" on it). self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"') def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self): - # Post-DEL state: active row w. empty hand → quota's spent, the - # query param shouldn't bypass GATE VIEW. Landing branch wins. + # Post-DEL state: active row w. empty hand + NO paid-through + # credit → the user still needs to gatekeeper. Landing wins; + # GATE VIEW button shown. from apps.gameboard.models import MySeaDraw MySeaDraw.objects.create( user=self.user, spread="situation-action-outcome", @@ -1810,6 +2205,23 @@ class MySeaPhasePickerQueryParamTest(TestCase): self.assertContains(response, 'data-phase="landing"') self.assertContains(response, 'id="id_my_sea_gate_view_btn"') + def test_paid_through_with_empty_hand_renders_paid_draw_btn_not_free(self): + """User-reported bug 2026-05-23 — after PAID DRAW commit, if the + user navigates away without drawing, the landing button must + stay as PAID DRAW (not revert to FREE DRAW). Preserved-row + + `paid_through_at` is the regression-pinning state.""" + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=self.target.id, hand=[], + paid_through_at=timezone.now(), + ) + response = self.client.get(reverse("my_sea")) + self.assertContains(response, 'data-phase="landing"') + self.assertContains(response, 'id="id_my_sea_paid_draw_btn"') + self.assertNotContains(response, 'id="id_draw_sea_btn"') + self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"') + class SelectMySeaTokenTest(TestCase): """Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 5a87008..8eff026 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -211,43 +211,56 @@ def my_sea(request): 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 hand_complete = active_draw.is_hand_complete hand_empty = active_draw.is_hand_empty else: default_spread = "situation-action-outcome" saved_hand = [] - next_free_draw_at = None hand_complete = False hand_empty = True - # Picker is the active phase iff the user has a non-empty hand in - # progress (or completed). Empty-hand active draws (post-DEL) fall - # back to the landing — but render GATE VIEW instead of FREE DRAW - # (the daily quota's spent already; landing's primary nav routes to - # the upcoming gatekeeper). New users + post-24h users land on the - # standard FREE DRAW landing. - # - # `?phase=picker` query param (set by PAID DRAW's redirect) forces - # the picker even when active_draw is None — the user just paid a - # token, so drop them straight into the picker rather than making - # them click FREE DRAW first. Only honored when active_draw is None - # (post-PAID-DRAW state); existing rows route through the normal - # logic above so the param can't accidentally bypass a GATE VIEW - # or empty-hand state. - phase_param = request.GET.get("phase") == "picker" - show_picker = (active_draw is not None and not hand_empty) or ( - active_draw is None and phase_param + # Brief banner's "next free draw at" — prefer the User's cooldown + # anchor (`User.last_free_draw_at + 24h`, set on the first card of + # the FREE DRAW path; persists across PAID DRAW commits per user- + # spec 2026-05-23). Falls back to the active row's own + # `next_free_draw_at` for legacy rows (or test fixtures that bypass + # `my_sea_lock`). + next_free_draw_at = ( + request.user.next_free_draw_at + or (active_draw.next_free_draw_at if active_draw is not None else None) ) - quota_spent = active_draw is not None # any active row = quota committed - # Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence. - # `deposit_reserved` toggles the landing primary from GATE VIEW to - # PAID DRAW (one-click commit of the already-deposited token). - # `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads - # don't lose the JS-only animation state. + # Sprint 6 iter 6b + 2026-05-23 fix — landing center-btn state machine. + # The user is "in cooldown" iff a `MySeaDraw` row exists (the row was + # created at first card-draw of the cycle + survives PAID DRAW commit). + # Within cooldown: + # + # deposit reserved (at gatekeeper) → PAID DRAW (commits + picker) + # paid-through credit set → PAID DRAW (navigates + picker) + # neither → GATE VIEW + # + # Outside cooldown (no row): → FREE DRAW + # + # The two PAID DRAW states share one button label so the user sees a + # stable "you're in a paid cycle" cue across navigation — user- + # reported bug 2026-05-23: PAID DRAW used to revert to FREE DRAW + # after the row was deleted at commit time. deposit_reserved = ( active_draw is not None and active_draw.deposit_token_id is not None ) + paid_through = ( + active_draw is not None and active_draw.paid_through_at is not None + ) + in_cooldown = active_draw is not None + show_paid_draw = in_cooldown and (deposit_reserved or paid_through) + show_gate_view = in_cooldown and not show_paid_draw hand_non_empty = active_draw is not None and bool(active_draw.hand) + # Picker is the active phase iff: + # - the user has a non-empty hand in progress / complete, OR + # - `?phase=picker` query param is set AND the user is in a paid + # cycle (deposit reserved OR paid-through credit set) — covers + # the `my_sea_paid_draw` redirect + lets the PAID DRAW landing + # button send the user back to the picker via a GET. + phase_param = request.GET.get("phase") == "picker" + show_picker = hand_non_empty or (phase_param and show_paid_draw) # Per-position lookup for the template — keyed by the position slug # ("lay", "cover", ...) so each `.sea-pos-` block can render @@ -291,8 +304,10 @@ def my_sea(request): "next_free_draw_at": next_free_draw_at, "hand_complete": hand_complete, "show_picker": show_picker, - "quota_spent": quota_spent, + "show_paid_draw": show_paid_draw, + "show_gate_view": show_gate_view, "deposit_reserved": deposit_reserved, + "paid_through": paid_through, "hand_non_empty": hand_non_empty, "page_class": "page-gameboard page-my-sea", }) @@ -347,19 +362,41 @@ def my_sea_lock(request): # first-card moment. if existing.spread != spread: return JsonResponse({"error": "spread_mismatch"}, status=409) + # If this row carried a paid-through credit (set by `my_sea_paid_ + # draw` at commit time) AND we're transitioning empty→non-empty, + # the credit is being consumed by this draw — clear it so the + # next attempt requires a fresh gatekeeper deposit (user-spec + # 2026-05-23: "each redraw needs a new token"). + was_empty = not existing.hand existing.hand = hand - existing.save(update_fields=["hand"]) + update_fields = ["hand"] + if (was_empty and hand + and existing.paid_through_at is not None): + existing.paid_through_at = None + update_fields.append("paid_through_at") + existing.save(update_fields=update_fields) return JsonResponse({ "ok": True, - "next_free_draw_at": existing.next_free_draw_at.isoformat(), + "next_free_draw_at": ( + request.user.next_free_draw_at.isoformat() + if request.user.next_free_draw_at else None + ), "hand_complete": existing.is_hand_complete, }) - # First card draw → quota commit. Create the row. + # First card draw of a fresh cycle (no row exists). If the user's + # free-draw cooldown isn't active, this is a FREE DRAW — anchor the + # 24h cooldown to the User now (NOT to the row's created_at, per + # user-spec 2026-05-23: the cooldown stays put even across PAID + # DRAWs in the same cycle). sig_id = request.user.significator_id if sig_id is None: return JsonResponse({"error": "no_significator"}, status=400) + if not request.user.free_draw_cooldown_active: + request.user.last_free_draw_at = timezone.now() + request.user.save(update_fields=["last_free_draw_at"]) + draw = MySeaDraw.objects.create( user=request.user, spread=spread, @@ -369,7 +406,10 @@ def my_sea_lock(request): ) return JsonResponse({ "ok": True, - "next_free_draw_at": draw.next_free_draw_at.isoformat(), + "next_free_draw_at": ( + request.user.next_free_draw_at.isoformat() + if request.user.next_free_draw_at else None + ), "hand_complete": draw.is_hand_complete, }) @@ -471,26 +511,42 @@ def my_sea_refund_token(request): @login_required(login_url="/") @require_POST def my_sea_paid_draw(request): - """Commit the deposited token + drop the active_draw row so the - user returns to a fresh "able-to-draw-now" state. Without the row, - `quota_spent` resolves to False on the next my-sea render → the - user can draw cards immediately (the token they just spent earns - them this 24h cycle's worth of draws). + """Commit the deposited token + mark the row as paid-through so the + PAID DRAW button label persists if the user navigates away before + drawing cards (user-reported bug 2026-05-23: PAID DRAW was reverting + to FREE DRAW after one navigation cycle because the row was deleted + at commit time, wiping the cooldown state). - The token is debited via `debit_my_sea_token` (FREE/TITHE consumed; - COIN 24h cooldown + unequipped; PASS no-op). The row is then - deleted (rather than just reset) — user-spec 2026-05-20: keeping - the row but resetting created_at left `quota_spent=True` on the - next view, looping the user back to GATE VIEW. Delete sidesteps - that entirely. + Semantics: + - debit_my_sea_token consumes the deposited token (FREE/TITHE + deleted; COIN: 24h cooldown + unequip; PASS/BAND: no-op). + - `deposit_token_id` + `deposit_reserved_at` cleared (token spent, + no longer reserved). + - `paid_through_at = now` — sticky credit marker. Drives the + landing-button logic in `my_sea` (PAID DRAW button stays so the + user can re-enter the picker without another gatekeeper visit + as long as `hand` stays empty). + - `hand = []` — fresh start per user-spec 2026-05-23 ("clear hand + on PAID DRAW commit"). + - `User.last_free_draw_at` is NOT touched. The 24h cooldown stays + anchored to the original FREE DRAW moment (NOT the paid draw). Redirects to /gameboard/my-sea/?phase=picker so the user lands - directly in the picker (skipping the FREE DRAW landing click). + directly in the picker after the commit. """ from django.urls import reverse from apps.lyric.models import Token active_draw = active_draw_for(request.user) - if active_draw is None or active_draw.deposit_token_id is None: + if active_draw is None: + return redirect("my_sea") + # Paid-through credit already set (no deposit currently reserved) — + # this is the user clicking PAID DRAW on the landing AFTER an earlier + # commit, to re-enter the picker. No token debit, just route to the + # picker (the `paid_through_at` credit stays until the first card + # lock consumes it in `my_sea_lock`). + if active_draw.deposit_token_id is None: + if active_draw.paid_through_at is not None: + return redirect(reverse("my_sea") + "?phase=picker") return redirect("my_sea") token = Token.objects.filter( pk=active_draw.deposit_token_id, user=request.user, @@ -503,7 +559,14 @@ def my_sea_paid_draw(request): active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"]) return redirect("my_sea") debit_my_sea_token(request.user, token) - active_draw.delete() + active_draw.deposit_token_id = None + active_draw.deposit_reserved_at = None + active_draw.paid_through_at = timezone.now() + active_draw.hand = [] + active_draw.save(update_fields=[ + "deposit_token_id", "deposit_reserved_at", + "paid_through_at", "hand", + ]) return redirect(reverse("my_sea") + "?phase=picker") diff --git a/src/apps/lyric/migrations/0013_user_last_free_draw_at.py b/src/apps/lyric/migrations/0013_user_last_free_draw_at.py new file mode 100644 index 0000000..12298e1 --- /dev/null +++ b/src/apps/lyric/migrations/0013_user_last_free_draw_at.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-23 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0012_seed_shop_shoptalk'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_free_draw_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index c5c7e38..c0e7ef0 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -135,6 +135,15 @@ class User(AbstractBaseUser): on_delete=models.SET_NULL, related_name="+", ) significator_reversed = models.BooleanField(default=False) + # My Sea free-draw cooldown anchor — the timestamp of the user's most + # recent TOKENLESS first-card-draw of a 24h cycle. Set when the user + # creates a MySeaDraw row via the FREE DRAW path (button on the my-sea + # landing); PAID DRAW deliberately does NOT update it, so the next + # free draw is always anchored to the original free-draw moment, not + # the most recent paid one (user-spec 2026-05-23). Drives the Brief + # banner's next-free-draw timestamp + the landing-button state machine + # (FREE DRAW vs GATE VIEW vs PAID DRAW). + last_free_draw_at = models.DateTimeField(null=True, blank=True) ap_public_key = models.TextField(blank=True, default="") ap_private_key = models.TextField(blank=True, default="") @@ -158,6 +167,47 @@ class User(AbstractBaseUser): REQUIRED_FIELDS = [] USERNAME_FIELD = "email" + # ── My Sea free-draw cooldown helpers ──────────────────────────────── + # Pair w. `last_free_draw_at` above. The cooldown anchors to the FREE + # DRAW moment (NOT to any subsequent paid draws), so the Brief banner + # surfaces "next free draw at" relative to the user's actual cycle + # start. PAID DRAWs commit their tokens against this same cooldown + # window — they don't reset it. + + @property + def sig_face(self): + """Rendering payload for the saved sig in `_applet-my-sign.html`. + `significator_reversed` is the POLARITY axis (FLIP — True ↔ levity, + per [[feedback-significator-reversed-is-polarity]]); the SPIN / + orientation axis is never persisted, so the saved sig is always + rendered upright in its polarity (reversed=False to `applet_face`). + Returns `None` when no sig is saved.""" + if self.significator is None: + return None + polarity = 'levity' if self.significator_reversed else 'gravity' + return self.significator.applet_face(polarity, reversed=False) + + @property + def free_draw_cooldown_active(self): + """True iff the user is currently inside the 24h cooldown window + triggered by their last tokenless free draw. False for fresh users + (never free-drew) and for users whose cooldown has elapsed.""" + from django.utils import timezone + from datetime import timedelta + if self.last_free_draw_at is None: + return False + return self.last_free_draw_at + timedelta(hours=24) > timezone.now() + + @property + def next_free_draw_at(self): + """Datetime when the user's next free draw becomes available + (`last_free_draw_at + 24h`). Returns None if the user has never + free-drawn.""" + from datetime import timedelta + if self.last_free_draw_at is None: + return None + return self.last_free_draw_at + timedelta(hours=24) + @property def active_title_display(self): """Render-ready string for "{username} the {title}" attributions — diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index c8577b4..3050f44 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -1486,17 +1486,17 @@ class MySeaGatekeeperPageTest(FunctionalTest): self.assertIsNone(draw.deposit_token_id) def test_paid_draw_commits_token_and_redirects_to_picker(self): - """PAID DRAW commits the deposited token (FREE token gets - consumed → user's token count drops by 1); iter-6c spec then - DROPS the active_draw row entirely + redirects to /gameboard/ - my-sea/?phase=picker so the user lands directly in the picker - ready to draw. Previously the row was preserved w. reset - created_at — that looped the user back to GATE VIEW - (`quota_spent=True` w. row present). See - [[sprint-my-sea-iter-6c-may20]] for the rationale.""" + """PAID DRAW commits the deposited token (FREE token consumed) + + redirects to /gameboard/my-sea/?phase=picker so the user lands + directly in the picker. Under the 2026-05-23 spec update, the + row is PRESERVED w. `paid_through_at` set (NOT deleted as + iter-6c originally did) — preservation keeps the PAID DRAW btn + on the landing across navigation cycles until the first card is + drawn. See [[sprint-paid-draw-persistence-may23]] for the + rationale + the user-reported bug it fixes.""" from apps.gameboard.models import MySeaDraw from apps.lyric.models import Token - self._save_empty_hand_draw() + draw = self._save_empty_hand_draw() free_count_before = Token.objects.filter( user=self.gamer, token_type=Token.FREE, ).count() @@ -1514,8 +1514,6 @@ class MySeaGatekeeperPageTest(FunctionalTest): ) ) paid_draw.click() - # Redirect lands on /gameboard/my-sea/?phase=picker so the user - # drops straight into the picker (no FREE-DRAW landing click). self.wait_for( lambda: self.assertIn( "/gameboard/my-sea/?phase=picker", self.browser.current_url @@ -1526,10 +1524,74 @@ class MySeaGatekeeperPageTest(FunctionalTest): Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), free_count_before - 1, ) - # Row dropped — `active_draw is None` is the signal that lets - # the my_sea view honour the `?phase=picker` override. - self.assertFalse( - MySeaDraw.objects.filter(user=self.gamer).exists() + # Row preserved + paid_through_at stamped (sticky PAID DRAW state). + draw.refresh_from_db() + self.assertIsNotNone(draw.paid_through_at, + "PAID DRAW commit must set paid_through_at (preserves PAID " + "DRAW btn on subsequent landing renders — user-reported " + "regression 2026-05-23)") + self.assertIsNone(draw.deposit_token_id) + self.assertEqual(draw.hand, []) + + def test_paid_draw_btn_persists_after_navigation_without_card_draw(self): + """User-reported bug 2026-05-23: after PAID DRAW commits, if the + user navigates away without drawing any cards, the landing + button reverts to FREE DRAW (under the old delete-at-commit + semantics) even though the free draw is still on cooldown. With + `paid_through_at` preserved on the row, the landing PAID DRAW + button stays visible across navigation cycles until the first + card of the paid session lands.""" + from apps.gameboard.models import MySeaDraw + from django.utils import timezone as dj_tz + self._save_empty_hand_draw() + # Set cooldown anchor explicitly so the user-level state matches + # the row's "cycle is live" indication. + self.gamer.last_free_draw_at = dj_tz.now() + self.gamer.save(update_fields=["last_free_draw_at"]) + self.create_pre_authenticated_session(self.email) + # Deposit + commit via the gatekeeper. + self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, + "form[action$='/my-sea/insert'] button.token-rails", + ) + ).click() + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + ) + ).click() + # Land in picker (?phase=picker). Now navigate AWAY without + # drawing any cards — to /gameboard/ — then back to /gameboard/ + # my-sea/. The landing must still show PAID DRAW (not FREE DRAW + # nor GATE VIEW). + self.wait_for( + lambda: self.assertIn("phase=picker", self.browser.current_url) + ) + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_applet_new_game") + ) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + # PAID DRAW button still rendered on the landing. + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn" + ) + ) + # FREE DRAW + GATE VIEW NOT shown. + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")), + 0, + "FREE DRAW btn must NOT show after PAID DRAW commit (regression)" + ) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, "#id_my_sea_gate_view_btn" + )), + 0, + "GATE VIEW btn must NOT show while paid-through credit is set" ) diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index 1bb75ec..8d9aa2d 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -479,9 +479,13 @@ body.page-billposts { width: var(--applet-card-w); aspect-ratio: 5 / 8; border-radius: 0.4rem; + // Gravity default — `--priUser` bg + `--terUser` ink. `--levity` + // modifier below inverts to `--secUser` bg + `--quiUser` ink, + // matching the page stage card's polarity convention (cf + // `_card-deck.scss:1002-1019` for levity, :1039-1057 for gravity). background: rgba(var(--priUser), 1); border: 0.12rem solid rgba(var(--secUser), 0.6); - color: rgba(var(--secUser), 1); + color: rgba(var(--terUser), 1); padding: 0.35rem; position: relative; display: flex; @@ -513,39 +517,55 @@ body.page-billposts { transform: rotate(180deg); } - // Card face — name + arcana stacked, centred in the remaining - // vertical space between the two corners. `flex: 1` lets the - // face absorb whatever's left after the absolute-positioned - // corners. + // Card face — qualifier + title + arcana stacked, centred in the + // remaining vertical space between the two corners. `gap: 0` so + // qualifier sits directly above the title at the title's own + // line-height; `.fan-card-arcana` carries its own margin-top to + // restore breathing room between title block and arcana label. .fan-card-face { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 0.2rem; + gap: 0; text-align: center; padding: 0 0.2rem; } + // Qualifier + title share the same typography (per `_card-deck.scss` + // convention at lines 568-572 / 1821-1823) — both bold, same size, + // same wrap, same line-height. Polarity color (gravity → --terUser, + // levity → --quiUser) lives on the parent — both elements inherit. + .fan-card-qualifier, .fan-card-name { margin: 0; font-size: calc(var(--applet-card-w) * 0.11); font-weight: 700; line-height: 1.15; text-wrap: balance; - color: rgba(var(--quiUser), 1); + color: inherit; } + .fan-card-qualifier:empty { display: none; } .fan-card-arcana { - margin: 0; + margin: calc(var(--applet-card-w) * 0.05) 0 0; font-size: calc(var(--applet-card-w) * 0.075); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.6; } - &.stage-card--reversed { transform: rotate(180deg); } + // Levity inversion — `--secUser` bg + `--quiUser` ink + `--priUser` + // border, mirroring `.sig-overlay[data-polarity="levity"] + // .sig-stage-card` at `_card-deck.scss:1002-1010`. + &.my-sign-applet-card--levity { + background: rgba(var(--secUser), 1); + border-color: rgba(var(--priUser), 0.6); + color: rgba(var(--quiUser), 1); + .fan-card-corner { color: rgba(var(--priUser), 0.75); } + .fan-card-arcana { color: rgba(var(--priUser), 1); } + } } // Stat block — mirrors the stage card's footprint (same 5:8 aspect + @@ -592,6 +612,22 @@ body.page-billposts { } } + // Polarity inversion of the stat block — mirrors the page convention + // where the stat block always carries the OPPOSITE-polarity colors of + // its sibling card (`_card-deck.scss:1042-1046` for the gravity case, + // levity inherits the default --priUser bg). Gravity polarity card → + // --secUser stat block (light), w. --quiUser label + --priUser + // keywords for contrast against the light bg. + .my-sign-applet-body[data-polarity="gravity"] .my-sign-applet-stat-block { + background: rgba(var(--secUser), 0.8); + border-color: rgba(var(--priUser), 0.15); + .stat-face-label { color: rgba(var(--quiUser), 1); } + .stat-keywords li { + color: rgba(var(--priUser), 1); + border-bottom-color: rgba(var(--priUser), 0.18); + } + } + .my-sign-applet-empty { flex: 1; display: flex; diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 1b54505..4f2bcfc 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -226,6 +226,9 @@ left: 50%; width: var(--sig-card-w); height: calc(var(--sig-card-w) * 8 / 5); + // Fallback bg when no `.tarot-fan-wrap[data-polarity]` parent (test + // fixtures, etc.). Live polarity inversion lives in the parent rule + // below — `.tarot-fan-wrap[data-polarity=...] .fan-stage-block`. background: rgba(var(--priUser), 1); border-radius: 0.4rem; border: 0.1rem solid rgba(var(--terUser), 0.15); @@ -259,6 +262,32 @@ } } +// Fan-stage-block polarity inversion — sig convention applied to Game Kit +// (user-spec 2026-05-23). The active card's polarity is mirrored onto the +// shared `.tarot-fan-wrap` ancestor by `game-kit.js:_populateStage` and +// `_flipActive` so the stat block can pick up the opposite-polarity bg +// without JS having to touch the stat block directly. +.tarot-fan-wrap[data-polarity="gravity"] .fan-stage-block { + background: rgba(var(--secUser), 1); + border-color: rgba(var(--priUser), 0.15); + color: rgba(var(--priUser), 1); + .stat-face-label { color: rgba(var(--quiUser), 1); } + .stat-keywords li { + color: rgba(var(--priUser), 1); + border-bottom-color: rgba(var(--priUser), 0.18); + } +} +.tarot-fan-wrap[data-polarity="levity"] .fan-stage-block { + background: rgba(var(--priUser), 1); + border-color: rgba(var(--terUser), 0.15); + color: rgba(var(--secUser), 1); + .stat-face-label { color: rgba(var(--terUser), 1); } + .stat-keywords li { + color: rgba(var(--quiUser), 1); + border-bottom-color: rgba(var(--terUser), 0.18); + } +} + .fan-card { position: absolute; inset: 0; @@ -1866,7 +1895,13 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f ); } -// Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage +// Sea stat block — reuses sig-select stat-block sizing, scoped to sea-stage. +// `background` left blank here; the `.sea-stage--gravity` / `.sea-stage-- +// levity` parent rules below set the polarity-aware bg (sig convention, +// user-spec 2026-05-23: stat block always carries the OPPOSITE-polarity +// color of its adjacent card — gravity card / secUser stat block, levity +// card / priUser stat block). Fallback `--priUser` 0.85 stays for any +// stray `.sea-stat-block` rendered outside the polarity-classed parent. .sea-stage-content .sea-stat-block { flex: 0 0 auto; width: var(--sig-card-w, 140px); @@ -1885,6 +1920,32 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f } } +// Sea stat block — polarity inversion (sig convention applied to sea, +// user-spec 2026-05-23). The drawn card's polarity (set by SeaDeal at +// stage-open time via `.sea-stage--gravity` / `.sea-stage--levity` on +// the stage root) cascades to the stat block here. SPIN/face-swap is +// unchanged — `.is-reversed` still just toggles which face renders; +// it does NOT shift the bg (orientation is preview-only, polarity is +// the persisted axis that paints the surfaces). +.sea-stage--gravity .sea-stat-block { + background: rgba(var(--secUser), 0.85); + border-color: rgba(var(--priUser), 0.15); + .stat-face-label { color: rgba(var(--quiUser), 1); } + .stat-keywords li { + color: rgba(var(--priUser), 1); + border-bottom-color: rgba(var(--priUser), 0.18); + } +} +.sea-stage--levity .sea-stat-block { + background: rgba(var(--priUser), 0.85); + border-color: rgba(var(--terUser), 0.15); + .stat-face-label { color: rgba(var(--terUser), 1); } + .stat-keywords li { + color: rgba(var(--quiUser), 1); + border-bottom-color: rgba(var(--terUser), 0.18); + } +} + @media (orientation: landscape) { html.sea-open body .container .navbar, html.sea-open body #id_footer { diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 4e7413d..1465098 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -194,8 +194,6 @@ body.page-gameboard { // FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line // font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle. #id_draw_sea_btn { - font-size: 0.75rem; - line-height: 1.1; white-space: normal; } @@ -655,17 +653,26 @@ body.page-gameboard { transform: rotate(180deg); } + // `gap: 0` so qualifier sits directly above the title at the + // title's own line-height (no flex gap between them); `.fan-card- + // arcana` carries its own margin-top to restore breathing room + // between title block and arcana label. .fan-card-face { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 0.2rem; + gap: 0; text-align: center; padding: 0 0.2rem; } + // Qualifier + title share the same typography (per `_card-deck.scss` + // convention at lines 568-572 / 1821-1823) — both bold, same size, + // same wrap, same line-height. Color inherits from the slot's + // polarity-driven `color:` (set on `--gravity` / `--levity`). + .fan-card-qualifier, .fan-card-name { margin: 0; font-size: calc(var(--slot-w) * 0.105); @@ -673,9 +680,10 @@ body.page-gameboard { line-height: 1.15; text-wrap: balance; } + .fan-card-qualifier:empty { display: none; } .fan-card-arcana { - margin: 0; + margin: calc(var(--slot-w) * 0.05) 0 0; font-size: calc(var(--slot-w) * 0.07); text-transform: uppercase; letter-spacing: 0.06em; @@ -685,35 +693,31 @@ body.page-gameboard { // Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity` // in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text; - // levity = inverted (secUser bg + priUser text). Explicit - // `.fan-card-name { color: ... }` override is required: the global - // `.fan-card-name` rule in `_card-deck.scss:569-570` hardcodes - // --quiUser, which is invisible on the levity --secUser bg (both - // light variants). Setting it back to --priUser here restores - // contrast. Corner-rank + arcana inherit from the slot's `color` - // (no global override) so they follow polarity automatically. + // levity = inverted (secUser bg + priUser text). `.fan-card-name`, + // `.fan-card-qualifier`, `.fan-card-corner` + `.fan-card-arcana` all + // pin `color: inherit` so they pick up the slot's polarity color + // uniformly — the global `.fan-card-face .fan-card-name { color: + // --terUser }` rule in `_card-deck.scss:376-383` loads AFTER gameboard + // (per `core.scss` import order) and otherwise wins at matching 0,2,0 + // specificity, stranding the title at --terUser while the qualifier + // inherits the slot color. Explicit `inherit` here at 0,3,0 beats it. .my-sea-slot--filled.my-sea-slot--gravity { background: rgba(var(--priUser), 1); color: rgba(var(--quiUser), 1); border-color: rgba(var(--secUser), 0.6); + .fan-card-corner { color: inherit; } + .fan-card-qualifier { color: inherit; } + .fan-card-name { color: inherit; } + .fan-card-arcana { color: inherit; opacity: 0.6; } } .my-sea-slot--filled.my-sea-slot--levity { background: rgba(var(--secUser), 1); color: rgba(var(--priUser), 1); border-color: rgba(var(--priUser), 1); - // `.fan-card-corner` carries a global `color: rgba(var(--secUser), - // 0.75)` rule in `_card-deck.scss:312-319` that out-specifics the - // slot's inherited color (specificity 0,1,0 wins over inheritance). - // On the levity --secUser bg this paints the corner-rank + suit- - // icon in the same color as the background → invisible. Same trap - // bit my-sign + game-kit earlier — fix is an explicit override at - // matching/higher specificity inside the polarity rule. - // `.fan-card-name` has its own `color: --quiUser` global rule - // (`_card-deck.scss:569-570`); `.fan-card-arcana` inherits but pin - // explicitly so a future global tweak can't silently re-break it. - .fan-card-corner { color: rgba(var(--priUser), 1); } - .fan-card-name { color: rgba(var(--priUser), 1); } - .fan-card-arcana { color: rgba(var(--priUser), 0.7); } + .fan-card-corner { color: inherit; } + .fan-card-qualifier { color: inherit; } + .fan-card-name { color: inherit; } + .fan-card-arcana { color: inherit; opacity: 0.7; } } .my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); } @@ -732,13 +736,15 @@ body.page-gameboard { border-width: 0.15rem !important; } - // Label — pulled tight against the slot's bottom border + vertically - // stretched via `scaleY(1.2)` to match the my_sea.html picker's - // `.sea-pos-label` typography (re-appropriated from `.sea-stack-name` - // in `_card-deck.scss:1671-1684`). Negative margin-top crosses the - // 0.12rem border so the label's top edge overlaps the bottom edge of - // the slot, per the user-locked spec ("practically overlapping"). + // Label — sibling of the slot inside the wrap, sits BELOW the slot + // box (mirrors the my_sea.html picker's `.sea-pos-label` placement). + // `margin-top: -0.15rem` crosses the slot's bottom border so the + // label's top edge sits flush against it; `position: relative; + // z-index: 2` keeps the label text rendering ATOP the slot's bottom + // border (dotted for empty slots, solid for filled). .my-sea-slot-label { + position: relative; + z-index: 2; margin-top: -0.15rem; padding: 0 0.2rem; font-size: 0.65rem; @@ -749,7 +755,7 @@ body.page-gameboard { text-align: center; white-space: nowrap; line-height: 1.1; - transform: scaleY(1.4); + transform: scaleY(1.3); transform-origin: top center; } // `.my-sea-slot-label--empty` intentionally has NO per-state recolor diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html index f4d3139..5c25e57 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-sign.html +++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html @@ -3,21 +3,42 @@ style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" >

My Sign

-
+
{% if request.user.significator %} {% with card=request.user.significator %} {# Mirrors the my_sign.html `.sig-stage-card` layout — corner #} - {# top-left, full name in the face, polarity-reversed mirror #} - {# at the bottom (pre-rotated). Sized to fill the applet's #} - {# vertical aperture via container queries in `_billboard.scss`. #} -
{{ card.corner_rank }} {% if card.suit_icon %}{% endif %}
-

{{ card.name_title }}

+ {# `request.user.sig_face` is the rendering payload from #} + {# `TarotCard.applet_face()` — mirrors `populateCard` in #} + {# `stage-card.js:135-144`: #} + {# • Polarity-split (cards 48-49, trumps 19-21): #} + {# single-line title, qualifier blank. #} + {# • Major + qualifier: title carries a trailing #} + {# comma + qualifier renders BELOW. #} + {# • Non-Major (middle court, Schizo / Nomad w. no #} + {# qualifier): qualifier renders ABOVE the title. #} + {% with face=request.user.sig_face %} + {% if face.qualifier_first %} +

{{ face.qualifier }}

+

{{ face.title }}

+ {% else %} +

{{ face.title }}

+

{{ face.qualifier }}

+ {% endif %} + {% endwith %}

{{ card.get_arcana_display }}

@@ -26,28 +47,17 @@
{# Stat block — same shape as my_sign.html's `.sig-stat-block` #} - {# (Emanation/Reversal face label + keyword list) but no SPIN #} - {# or FYI buttons since the applet is a read-only preview. The #} - {# face shown is keyed off significator_reversed: True → #} - {# reversal keywords (labelled "Reversal"), False → upright #} - {# (labelled "Emanation"). Mirrors the FYI panel populated by #} - {# `StageCard.populateKeywords` in my_sign.html's JS init. #} + {# (Emanation face label + keyword list) but no SPIN/FYI btns #} + {# since the applet is a read-only preview. Saved sigs persist #} + {# only the polarity axis (FLIP), never the orientation axis #} + {# (SPIN), so always render the upright/emanation face. #}
- {% if request.user.significator_reversed %} -

Reversal

-
    - {% for kw in card.keywords_reversed %} -
  • {{ kw }}
  • - {% endfor %} -
- {% else %} -

Emanation

-
    - {% for kw in card.keywords_upright %} -
  • {{ kw }}
  • - {% endfor %} -
- {% endif %} +

Emanation

+
    + {% for kw in card.keywords_upright %} +
  • {{ kw }}
  • + {% endfor %} +
{% endwith %} {% else %} diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index 6572983..37bc528 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -139,6 +139,7 @@ data-levity-qualifier="{{ card.levity_qualifier }}" data-gravity-qualifier="{{ card.gravity_qualifier }}" data-reversal-qualifier="{{ card.reversal_qualifier }}" + data-reversal-drops-qualifier="{{ card.reversal_drops_qualifier|yesno:'true,false' }}" data-levity-emanation="{{ card.levity_emanation }}" data-gravity-emanation="{{ card.gravity_emanation }}" data-levity-reversal="{{ card.levity_reversal }}" @@ -379,22 +380,18 @@ // On-load: if user has a saved sig, populate the stage preview AND // reveal the stat block (via .sig-stage--frozen) so the saved card - // appears alongside its emanation/reversal keywords — the page is - // read-only on landing while a sig is committed (hex is server-side - // hidden, DEL is the only action). The picker grid stays hidden - // until SCAN SIGN — but SCAN SIGN itself is gone in this state, so - // the user must DEL → reload to ever re-enter picker phase. + // appears alongside its emanation keywords — the page is read-only + // on landing while a sig is committed (hex is server-side hidden, + // DEL is the only action). The picker grid stays hidden until SCAN + // SIGN — but SCAN SIGN itself is gone in this state, so the user + // must DEL → reload to ever re-enter picker phase. // - // If the saved sig is reversed, also call _toggleOrientation() once - // so the stage card visually rotates 180° + the stat block swaps to - // its reversal face. The server-side `data-polarity` attribute on - // .my-sign-page already reflects the reversed flag (drives polarity- - // themed colors via the [data-polarity=...] CSS rules) but the - // visual rotation lives in the `stage-card--reversed` class which - // is JS-applied. Without this call the stage card lied: saved- - // reversed sigs rendered upright on landing while the My Sign - // applet (template-driven, reads significator_reversed directly) - // correctly rotated them — surfaces disagreed. + // `significator_reversed` is the POLARITY axis (reversed=True ↔ + // levity), already reflected in `data-polarity` on the page wrapper + // and threaded into `_polarity()` so `_populateStage` paints the + // correct levity/gravity qualifier on the upright face. The SPIN + // axis (.stage-card--reversed rotation) is preview-only and is NOT + // persisted — saved sigs always render upright in their polarity. var savedId = pageEl.dataset.currentCardId; if (savedId && grid) { var savedCardEl = grid.querySelector( @@ -402,9 +399,6 @@ if (savedCardEl) { _populateStage(savedCardEl); stage.classList.add('sig-stage--frozen'); - if (revInput.value === '1') { - _toggleOrientation(); - } } } diff --git a/src/templates/apps/gameboard/_partials/_applet-my-sea.html b/src/templates/apps/gameboard/_partials/_applet-my-sea.html index 9628c7c..bf5d4f3 100644 --- a/src/templates/apps/gameboard/_partials/_applet-my-sea.html +++ b/src/templates/apps/gameboard/_partials/_applet-my-sea.html @@ -23,8 +23,10 @@
{# Mirrors the my_sign.html `.sig-stage-card` layout — #} {# corner top-left, face w. name + arcana, mirror corner #} - {# bottom-right. Sized to fill the applet height via #} - {# container queries in `_gameboard.scss`. #} + {# bottom-right. Label is a SIBLING of the slot inside #} + {# the wrap so it sits BELOW the slot box (user-spec #} + {# 2026-05-23: same position as the my_sea.html picker's #} + {# `.sea-pos-label`). #}
@@ -33,7 +35,24 @@ {% if slot.card.suit_icon %}{% endif %}
-

{{ slot.card.name_title }}

+ {# `slot.face` is the rendering payload from `TarotCard. #} + {# applet_face()` — mirrors `populateCard` in #} + {# `stage-card.js`: #} + {# • Polarity-split (cards 19-21, 48-49): single-line #} + {# title, qualifier blank. #} + {# • Pattern B Major (2-5, 10-15, 22-35, 41): swapped #} + {# reversal name + polarity qualifier carried. #} + {# • Pattern B' Major (16-18): swapped reversal name, #} + {# no qualifier on reversal. #} + {# • Non-Major: qualifier ABOVE the title. #} + {# Empty `.fan-card-qualifier` is hidden by `:empty` CSS. #} + {% if slot.face.qualifier_first %} +

{{ slot.face.qualifier }}

+

{{ slot.face.title }}

+ {% else %} +

{{ slot.face.title }}

+

{{ slot.face.qualifier }}

+ {% endif %}

{{ slot.card.get_arcana_display }}

diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index cf122fa..82555a9 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -77,6 +77,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_ data-levity-qualifier="{{ card.levity_qualifier }}" data-gravity-qualifier="{{ card.gravity_qualifier }}" data-reversal-qualifier="{{ card.reversal_qualifier }}" + data-reversal-drops-qualifier="{{ card.reversal_drops_qualifier|yesno:'true,false' }}" data-levity-emanation="{{ card.levity_emanation }}" data-gravity-emanation="{{ card.gravity_emanation }}" data-levity-reversal="{{ card.levity_reversal }}" diff --git a/src/templates/apps/gameboard/_partials/_tarot_fan.html b/src/templates/apps/gameboard/_partials/_tarot_fan.html index 52f101c..8a5ee57 100644 --- a/src/templates/apps/gameboard/_partials/_tarot_fan.html +++ b/src/templates/apps/gameboard/_partials/_tarot_fan.html @@ -15,6 +15,7 @@ data-levity-qualifier="{{ card.levity_qualifier }}" data-gravity-qualifier="{{ card.gravity_qualifier }}" data-reversal-qualifier="{{ card.reversal_qualifier }}" + data-reversal-drops-qualifier="{{ card.reversal_drops_qualifier|yesno:'true,false' }}" data-levity-emanation="{{ card.levity_emanation }}" data-gravity-emanation="{{ card.gravity_emanation }}" data-levity-reversal="{{ card.levity_reversal }}" diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 59b5e61..caac204 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -52,14 +52,21 @@
- {% if deposit_reserved %} + {% if show_paid_draw %} + {# PAID DRAW — two underlying states collapse into one #} + {# button (user-spec 2026-05-23): #} + {# • `deposit_reserved` → POST commits the deposited #} + {# token via `my_sea_paid_draw` + redirects to picker. #} + {# • `paid_through` (token already spent this cycle, #} + {# hand still empty) → POST is a no-op commit branch #} + {# in the view; the view redirects to picker anyway. #}
{% csrf_token %}
- {% elif quota_spent %} + {% elif show_gate_view %}