fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).

**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].

**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).

**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].

**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).

**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].

**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.

**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.

**Files** (every uncommitted file folded in — session work + pre-existing modifications):

Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.

Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.

JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.

Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.

SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.

Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-23 15:06:35 -04:00
parent 53cd7afeb4
commit 92df686d80
24 changed files with 1298 additions and 172 deletions

View File

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

View File

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

View File

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

View File

@@ -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: `<reversal_qual>,`
/ `<polarity_qualifier>` 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 ''."""

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,7 @@ def latest_draw_slots(user):
"card": <TarotCard>, # 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(

View File

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

View File

@@ -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 `<p>` lands AFTER the name `<p>` 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

View File

@@ -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-<name>` 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,21 +3,42 @@
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'billboard:my_sign' %}">My Sign</a></h2>
<div class="my-sign-applet-body">
<div class="my-sign-applet-body"
data-polarity="{% if request.user.significator_reversed %}levity{% else %}gravity{% endif %}">
{% 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`. #}
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
{# top-left, name + polarity qualifier in the face, mirror #}
{# corner bottom-right (pre-rotated). Sized to fill the #}
{# applet's vertical aperture via container queries in #}
{# `_billboard.scss`. `significator_reversed` is the POLARITY #}
{# axis (True ↔ levity), so the saved sig is always upright #}
{# in its polarity — no `.stage-card--reversed` rotation. #}
<div class="my-sign-applet-card my-sign-applet-card--{% if request.user.significator_reversed %}levity{% else %}gravity{% endif %}"
data-card-id="{{ card.id }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
<div class="fan-card-face">
<p class="fan-card-name">{{ card.name_title }}</p>
{# `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 %}
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
<p class="fan-card-name">{{ face.title }}</p>
{% else %}
<p class="fan-card-name">{{ face.title }}</p>
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
{% endif %}
{% endwith %}
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
</div>
<div class="fan-card-corner fan-card-corner--br">
@@ -26,28 +47,17 @@
</div>
</div>
{# 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. #}
<div class="my-sign-applet-stat-block">
{% if request.user.significator_reversed %}
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords">
{% for kw in card.keywords_reversed %}
<li>{{ kw }}</li>
{% endfor %}
</ul>
{% else %}
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords">
{% for kw in card.keywords_upright %}
<li>{{ kw }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endwith %}
{% else %}

View File

@@ -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();
}
}
}

View File

@@ -23,8 +23,10 @@
<div class="my-sea-slot-wrap">
{# 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`). #}
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}"
data-position="{{ slot.position }}"
data-card-id="{{ slot.card.id }}">
@@ -33,7 +35,24 @@
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
</div>
<div class="fan-card-face">
<p class="fan-card-name">{{ slot.card.name_title }}</p>
{# `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 %}
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
<p class="fan-card-name">{{ slot.face.title }}</p>
{% else %}
<p class="fan-card-name">{{ slot.face.title }}</p>
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
{% endif %}
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
</div>
<div class="fan-card-corner fan-card-corner--br">

View File

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

View File

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

View File

@@ -52,14 +52,21 @@
<div class="table-hex-border">
<div class="table-hex">
<div class="table-center">
{% 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. #}
<form method="POST" action="{% url 'my_sea_paid_draw' %}" style="display:contents">
{% csrf_token %}
<button type="submit"
id="id_my_sea_paid_draw_btn"
class="btn btn-primary">PAID<br>DRAW</button>
</form>
{% elif quota_spent %}
{% elif show_gate_view %}
<button id="id_my_sea_gate_view_btn"
type="button"
class="btn btn-primary"