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
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:
@@ -966,5 +966,28 @@ class BillboardAppletMySignTest(TestCase):
|
|||||||
response = self.client.get("/billboard/")
|
response = self.client.get("/billboard/")
|
||||||
self.assertContains(response, "my-sign-applet-card")
|
self.assertContains(response, "my-sign-applet-card")
|
||||||
self.assertContains(response, f'data-card-id="{target.id}"')
|
self.assertContains(response, f'data-card-id="{target.id}"')
|
||||||
# significator_reversed = True → card carries stage-card--reversed class
|
# significator_reversed = True ↔ polarity=levity (per convention).
|
||||||
self.assertContains(response, "stage-card--reversed")
|
# 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)
|
||||||
|
|||||||
18
src/apps/epic/migrations/0009_reversal_drops_qualifier.py
Normal file
18
src/apps/epic/migrations/0009_reversal_drops_qualifier.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -279,7 +279,8 @@ class TarotCard(models.Model):
|
|||||||
slug = models.SlugField(max_length=120)
|
slug = models.SlugField(max_length=120)
|
||||||
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
|
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
|
||||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
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='')
|
levity_qualifier = models.CharField(max_length=100, blank=True, default='')
|
||||||
gravity_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)
|
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.gravity_reversal
|
||||||
return self.reversal_qualifier or self.emanation_for(polarity)
|
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
|
@property
|
||||||
def name_group(self):
|
def name_group(self):
|
||||||
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
|
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ var StageCard = (function () {
|
|||||||
levity_qualifier: el.dataset.levityQualifier || '',
|
levity_qualifier: el.dataset.levityQualifier || '',
|
||||||
gravity_qualifier: el.dataset.gravityQualifier || '',
|
gravity_qualifier: el.dataset.gravityQualifier || '',
|
||||||
reversal_qualifier: el.dataset.reversalQualifier || '',
|
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,
|
// Polarity-split title overrides — non-blank for cards 48-49 only,
|
||||||
// where each polarity (and within each polarity, each axis state)
|
// where each polarity (and within each polarity, each axis state)
|
||||||
// has a fully distinct title rather than a shared name + qualifier.
|
// 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:
|
// assign classes per-arcana here so each branch lands in the right slot:
|
||||||
// Major / polarity-split — title on top → .name carried by DOM-second
|
// Major / polarity-split — title on top → .name carried by DOM-second
|
||||||
// Non-major — qualifier on top → .qualifier 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');
|
var slots = stageCard.querySelectorAll('.fan-card-face-reversal > p');
|
||||||
if (slots.length === 2) {
|
if (slots.length === 2) {
|
||||||
var bottomEl = slots[0]; // DOM-first → visually bottom after spin
|
var bottomEl = slots[0]; // DOM-first → visually bottom after spin
|
||||||
@@ -166,8 +181,23 @@ var StageCard = (function () {
|
|||||||
_setTitle(topEl, reversalOverride, card);
|
_setTitle(topEl, reversalOverride, card);
|
||||||
bottomEl.className = 'fan-card-reversal-qualifier';
|
bottomEl.className = 'fan-card-reversal-qualifier';
|
||||||
bottomEl.textContent = '';
|
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) {
|
} 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';
|
topEl.className = 'fan-card-reversal-name';
|
||||||
_setTitle(topEl, title + ',', card);
|
_setTitle(topEl, title + ',', card);
|
||||||
bottomEl.className = 'fan-card-reversal-qualifier';
|
bottomEl.className = 'fan-card-reversal-qualifier';
|
||||||
|
|||||||
@@ -135,6 +135,164 @@ class TarotCardReversalForTest(SimpleTestCase):
|
|||||||
self.assertEqual(c.reversal_for('levity'), 'The Nomad')
|
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):
|
class TarotCardNameSplitTest(SimpleTestCase):
|
||||||
"""TarotCard.name_group / name_title — colon-split parsing."""
|
"""TarotCard.name_group / name_title — colon-split parsing."""
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ def card_dict(card, reversal_prob=STACK_REVERSAL_PROBABILITY):
|
|||||||
'levity_qualifier': card.levity_qualifier,
|
'levity_qualifier': card.levity_qualifier,
|
||||||
'gravity_qualifier': card.gravity_qualifier,
|
'gravity_qualifier': card.gravity_qualifier,
|
||||||
'reversal_qualifier': card.reversal_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)
|
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
|
||||||
'levity_emanation': card.levity_emanation,
|
'levity_emanation': card.levity_emanation,
|
||||||
'gravity_emanation': card.gravity_emanation,
|
'gravity_emanation': card.gravity_emanation,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -68,6 +68,7 @@ def latest_draw_slots(user):
|
|||||||
"card": <TarotCard>, # None if not yet drawn
|
"card": <TarotCard>, # None if not yet drawn
|
||||||
"reversed": bool, # False if not yet drawn
|
"reversed": bool, # False if not yet drawn
|
||||||
"polarity": "gravity", # "" 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.
|
Empty list = no active draw OR active draw w. empty hand.
|
||||||
"""
|
"""
|
||||||
@@ -89,12 +90,17 @@ def latest_draw_slots(user):
|
|||||||
slots = []
|
slots = []
|
||||||
for pos in order:
|
for pos in order:
|
||||||
entry = by_position.get(pos)
|
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({
|
slots.append({
|
||||||
"position": pos,
|
"position": pos,
|
||||||
"label": labels.get(pos, ""),
|
"label": labels.get(pos, ""),
|
||||||
"card": cards_by_id.get(entry["card_id"]) if entry else None,
|
"card": card,
|
||||||
"reversed": entry.get("reversed", False) if entry else False,
|
"reversed": reversed_flag,
|
||||||
"polarity": entry.get("polarity", "") if entry else "",
|
"polarity": polarity,
|
||||||
|
"face": (card.applet_face(polarity or "gravity", reversed_flag)
|
||||||
|
if card else None),
|
||||||
})
|
})
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
@@ -144,6 +150,16 @@ class MySeaDraw(models.Model):
|
|||||||
# + `debit_my_sea_token` for the priority chain + per-type rules.
|
# + `debit_my_sea_token` for the priority chain + per-type rules.
|
||||||
deposit_token_id = models.IntegerField(null=True, blank=True)
|
deposit_token_id = models.IntegerField(null=True, blank=True)
|
||||||
deposit_reserved_at = models.DateTimeField(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:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
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
|
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
|
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
|
'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)
|
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||||
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
|
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
|
||||||
return MySeaDraw.objects.filter(
|
return MySeaDraw.objects.filter(
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ var GameKit = (function () {
|
|||||||
// and per-polarity qualifier rendering stay consistent with the data).
|
// and per-polarity qualifier rendering stay consistent with the data).
|
||||||
StageCard.populateCard(cardEl, card, _polarity);
|
StageCard.populateCard(cardEl, card, _polarity);
|
||||||
cardEl.dataset.polarity = _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, {
|
StageCard.populateKeywords(stageBlock, card.keywords_upright, card.keywords_reversed, {
|
||||||
uprightSel: '#id_fan_stat_upright',
|
uprightSel: '#id_fan_stat_upright',
|
||||||
@@ -156,6 +161,8 @@ var GameKit = (function () {
|
|||||||
var card = StageCard.fromDataset(active);
|
var card = StageCard.fromDataset(active);
|
||||||
StageCard.populateCard(active, card, _polarity);
|
StageCard.populateCard(active, card, _polarity);
|
||||||
active.dataset.polarity = _polarity;
|
active.dataset.polarity = _polarity;
|
||||||
|
// Mirror onto the wrap so the stage block re-invertds in lockstep.
|
||||||
|
if (fanWrap) fanWrap.dataset.polarity = _polarity;
|
||||||
}, 250);
|
}, 250);
|
||||||
// Clear the in-flight flag at animation end. Using setTimeout (not
|
// Clear the in-flight flag at animation end. Using setTimeout (not
|
||||||
// anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests.
|
// anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests.
|
||||||
|
|||||||
@@ -195,6 +195,113 @@ class GameboardViewTest(TestCase):
|
|||||||
["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"],
|
["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):
|
def test_gameboard_shows_game_kit(self):
|
||||||
[_] = self.parsed.cssselect("#id_game_kit")
|
[_] = self.parsed.cssselect("#id_game_kit")
|
||||||
|
|
||||||
@@ -1123,6 +1230,79 @@ class MySeaLockHandViewTest(TestCase):
|
|||||||
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
||||||
self.assertIsNotNone(parsed)
|
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):
|
def test_lock_post_within_quota_upserts_same_row(self):
|
||||||
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
||||||
# Second POST w. same spread updates the existing row's hand
|
# Second POST w. same spread updates the existing row's hand
|
||||||
@@ -1722,13 +1902,28 @@ class MySeaPaidDrawViewTest(TestCase):
|
|||||||
self.client.post(self.url)
|
self.client.post(self.url)
|
||||||
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
|
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
|
||||||
|
|
||||||
def test_paid_draw_deletes_active_draw_row(self):
|
def test_paid_draw_preserves_row_and_sets_paid_through_at(self):
|
||||||
# User-spec 2026-05-20: PAID DRAW commits the token + drops the row
|
# User-spec 2026-05-23 (replaces the 2026-05-20 "delete row"
|
||||||
# entirely so the user returns to a fresh "able-to-draw-now" state
|
# spec): PAID DRAW preserves the row + sets `paid_through_at`
|
||||||
# (instead of the buggy "row preserved → GATE VIEW loop" semantics).
|
# 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
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
before = timezone.now()
|
||||||
self.client.post(self.url)
|
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):
|
def test_paid_draw_redirects_to_my_sea_with_phase_picker(self):
|
||||||
# User-spec 2026-05-20: drop the user directly into the picker
|
# 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)
|
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):
|
class MySeaPhasePickerQueryParamTest(TestCase):
|
||||||
"""Sprint 6 iter 6c — `?phase=picker` query param forces picker phase
|
"""`?phase=picker` query param forces picker phase when the user is
|
||||||
when no active_draw row exists (the just-after-PAID-DRAW state).
|
in a paid cycle (post-PAID-DRAW commit, hand still empty). Updated
|
||||||
Without the param, no-active-draw users default to the FREE DRAW
|
2026-05-23 — previously the param worked w. no active row (the old
|
||||||
landing. With it, they drop straight into the picker so they can
|
"delete row at PAID DRAW" semantics). Under the new "preserve row +
|
||||||
start drawing immediately (the token they just spent earns this)."""
|
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):
|
def setUp(self):
|
||||||
from apps.epic.models import personal_sig_cards
|
from apps.epic.models import personal_sig_cards
|
||||||
@@ -1792,15 +2178,24 @@ class MySeaPhasePickerQueryParamTest(TestCase):
|
|||||||
self.assertContains(response, 'data-phase="landing"')
|
self.assertContains(response, 'data-phase="landing"')
|
||||||
self.assertContains(response, 'id="id_draw_sea_btn"')
|
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")
|
response = self.client.get(reverse("my_sea") + "?phase=picker")
|
||||||
self.assertContains(response, 'data-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"')
|
self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"')
|
||||||
|
|
||||||
def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self):
|
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
|
# Post-DEL state: active row w. empty hand + NO paid-through
|
||||||
# query param shouldn't bypass GATE VIEW. Landing branch wins.
|
# credit → the user still needs to gatekeeper. Landing wins;
|
||||||
|
# GATE VIEW button shown.
|
||||||
from apps.gameboard.models import MySeaDraw
|
from apps.gameboard.models import MySeaDraw
|
||||||
MySeaDraw.objects.create(
|
MySeaDraw.objects.create(
|
||||||
user=self.user, spread="situation-action-outcome",
|
user=self.user, spread="situation-action-outcome",
|
||||||
@@ -1810,6 +2205,23 @@ class MySeaPhasePickerQueryParamTest(TestCase):
|
|||||||
self.assertContains(response, 'data-phase="landing"')
|
self.assertContains(response, 'data-phase="landing"')
|
||||||
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
|
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):
|
class SelectMySeaTokenTest(TestCase):
|
||||||
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
|
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
|
||||||
|
|||||||
@@ -211,43 +211,56 @@ def my_sea(request):
|
|||||||
if active_draw is not None:
|
if active_draw is not None:
|
||||||
default_spread = active_draw.spread
|
default_spread = active_draw.spread
|
||||||
saved_hand = active_draw.hand
|
saved_hand = active_draw.hand
|
||||||
next_free_draw_at = active_draw.next_free_draw_at
|
|
||||||
hand_complete = active_draw.is_hand_complete
|
hand_complete = active_draw.is_hand_complete
|
||||||
hand_empty = active_draw.is_hand_empty
|
hand_empty = active_draw.is_hand_empty
|
||||||
else:
|
else:
|
||||||
default_spread = "situation-action-outcome"
|
default_spread = "situation-action-outcome"
|
||||||
saved_hand = []
|
saved_hand = []
|
||||||
next_free_draw_at = None
|
|
||||||
hand_complete = False
|
hand_complete = False
|
||||||
hand_empty = True
|
hand_empty = True
|
||||||
# Picker is the active phase iff the user has a non-empty hand in
|
# Brief banner's "next free draw at" — prefer the User's cooldown
|
||||||
# progress (or completed). Empty-hand active draws (post-DEL) fall
|
# anchor (`User.last_free_draw_at + 24h`, set on the first card of
|
||||||
# back to the landing — but render GATE VIEW instead of FREE DRAW
|
# the FREE DRAW path; persists across PAID DRAW commits per user-
|
||||||
# (the daily quota's spent already; landing's primary nav routes to
|
# spec 2026-05-23). Falls back to the active row's own
|
||||||
# the upcoming gatekeeper). New users + post-24h users land on the
|
# `next_free_draw_at` for legacy rows (or test fixtures that bypass
|
||||||
# standard FREE DRAW landing.
|
# `my_sea_lock`).
|
||||||
#
|
next_free_draw_at = (
|
||||||
# `?phase=picker` query param (set by PAID DRAW's redirect) forces
|
request.user.next_free_draw_at
|
||||||
# the picker even when active_draw is None — the user just paid a
|
or (active_draw.next_free_draw_at if active_draw is not None else None)
|
||||||
# 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
|
|
||||||
)
|
)
|
||||||
quota_spent = active_draw is not None # any active row = quota committed
|
# Sprint 6 iter 6b + 2026-05-23 fix — landing center-btn state machine.
|
||||||
# Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence.
|
# The user is "in cooldown" iff a `MySeaDraw` row exists (the row was
|
||||||
# `deposit_reserved` toggles the landing primary from GATE VIEW to
|
# created at first card-draw of the cycle + survives PAID DRAW commit).
|
||||||
# PAID DRAW (one-click commit of the already-deposited token).
|
# Within cooldown:
|
||||||
# `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads
|
#
|
||||||
# don't lose the JS-only animation state.
|
# 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 = (
|
deposit_reserved = (
|
||||||
active_draw is not None and active_draw.deposit_token_id is not None
|
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)
|
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
|
# Per-position lookup for the template — keyed by the position slug
|
||||||
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
# ("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,
|
"next_free_draw_at": next_free_draw_at,
|
||||||
"hand_complete": hand_complete,
|
"hand_complete": hand_complete,
|
||||||
"show_picker": show_picker,
|
"show_picker": show_picker,
|
||||||
"quota_spent": quota_spent,
|
"show_paid_draw": show_paid_draw,
|
||||||
|
"show_gate_view": show_gate_view,
|
||||||
"deposit_reserved": deposit_reserved,
|
"deposit_reserved": deposit_reserved,
|
||||||
|
"paid_through": paid_through,
|
||||||
"hand_non_empty": hand_non_empty,
|
"hand_non_empty": hand_non_empty,
|
||||||
"page_class": "page-gameboard page-my-sea",
|
"page_class": "page-gameboard page-my-sea",
|
||||||
})
|
})
|
||||||
@@ -347,19 +362,41 @@ def my_sea_lock(request):
|
|||||||
# first-card moment.
|
# first-card moment.
|
||||||
if existing.spread != spread:
|
if existing.spread != spread:
|
||||||
return JsonResponse({"error": "spread_mismatch"}, status=409)
|
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.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({
|
return JsonResponse({
|
||||||
"ok": True,
|
"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,
|
"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
|
sig_id = request.user.significator_id
|
||||||
if sig_id is None:
|
if sig_id is None:
|
||||||
return JsonResponse({"error": "no_significator"}, status=400)
|
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(
|
draw = MySeaDraw.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
spread=spread,
|
spread=spread,
|
||||||
@@ -369,7 +406,10 @@ def my_sea_lock(request):
|
|||||||
)
|
)
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"ok": True,
|
"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,
|
"hand_complete": draw.is_hand_complete,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -471,26 +511,42 @@ def my_sea_refund_token(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_paid_draw(request):
|
def my_sea_paid_draw(request):
|
||||||
"""Commit the deposited token + drop the active_draw row so the
|
"""Commit the deposited token + mark the row as paid-through so the
|
||||||
user returns to a fresh "able-to-draw-now" state. Without the row,
|
PAID DRAW button label persists if the user navigates away before
|
||||||
`quota_spent` resolves to False on the next my-sea render → the
|
drawing cards (user-reported bug 2026-05-23: PAID DRAW was reverting
|
||||||
user can draw cards immediately (the token they just spent earns
|
to FREE DRAW after one navigation cycle because the row was deleted
|
||||||
them this 24h cycle's worth of draws).
|
at commit time, wiping the cooldown state).
|
||||||
|
|
||||||
The token is debited via `debit_my_sea_token` (FREE/TITHE consumed;
|
Semantics:
|
||||||
COIN 24h cooldown + unequipped; PASS no-op). The row is then
|
- debit_my_sea_token consumes the deposited token (FREE/TITHE
|
||||||
deleted (rather than just reset) — user-spec 2026-05-20: keeping
|
deleted; COIN: 24h cooldown + unequip; PASS/BAND: no-op).
|
||||||
the row but resetting created_at left `quota_spent=True` on the
|
- `deposit_token_id` + `deposit_reserved_at` cleared (token spent,
|
||||||
next view, looping the user back to GATE VIEW. Delete sidesteps
|
no longer reserved).
|
||||||
that entirely.
|
- `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
|
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 django.urls import reverse
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
active_draw = active_draw_for(request.user)
|
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")
|
return redirect("my_sea")
|
||||||
token = Token.objects.filter(
|
token = Token.objects.filter(
|
||||||
pk=active_draw.deposit_token_id, user=request.user,
|
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"])
|
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
|
||||||
return redirect("my_sea")
|
return redirect("my_sea")
|
||||||
debit_my_sea_token(request.user, token)
|
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")
|
return redirect(reverse("my_sea") + "?phase=picker")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
src/apps/lyric/migrations/0013_user_last_free_draw_at.py
Normal file
18
src/apps/lyric/migrations/0013_user_last_free_draw_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -135,6 +135,15 @@ class User(AbstractBaseUser):
|
|||||||
on_delete=models.SET_NULL, related_name="+",
|
on_delete=models.SET_NULL, related_name="+",
|
||||||
)
|
)
|
||||||
significator_reversed = models.BooleanField(default=False)
|
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_public_key = models.TextField(blank=True, default="")
|
||||||
ap_private_key = models.TextField(blank=True, default="")
|
ap_private_key = models.TextField(blank=True, default="")
|
||||||
|
|
||||||
@@ -158,6 +167,47 @@ class User(AbstractBaseUser):
|
|||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
USERNAME_FIELD = "email"
|
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
|
@property
|
||||||
def active_title_display(self):
|
def active_title_display(self):
|
||||||
"""Render-ready string for "{username} the {title}" attributions —
|
"""Render-ready string for "{username} the {title}" attributions —
|
||||||
|
|||||||
@@ -1486,17 +1486,17 @@ class MySeaGatekeeperPageTest(FunctionalTest):
|
|||||||
self.assertIsNone(draw.deposit_token_id)
|
self.assertIsNone(draw.deposit_token_id)
|
||||||
|
|
||||||
def test_paid_draw_commits_token_and_redirects_to_picker(self):
|
def test_paid_draw_commits_token_and_redirects_to_picker(self):
|
||||||
"""PAID DRAW commits the deposited token (FREE token gets
|
"""PAID DRAW commits the deposited token (FREE token consumed)
|
||||||
consumed → user's token count drops by 1); iter-6c spec then
|
+ redirects to /gameboard/my-sea/?phase=picker so the user lands
|
||||||
DROPS the active_draw row entirely + redirects to /gameboard/
|
directly in the picker. Under the 2026-05-23 spec update, the
|
||||||
my-sea/?phase=picker so the user lands directly in the picker
|
row is PRESERVED w. `paid_through_at` set (NOT deleted as
|
||||||
ready to draw. Previously the row was preserved w. reset
|
iter-6c originally did) — preservation keeps the PAID DRAW btn
|
||||||
created_at — that looped the user back to GATE VIEW
|
on the landing across navigation cycles until the first card is
|
||||||
(`quota_spent=True` w. row present). See
|
drawn. See [[sprint-paid-draw-persistence-may23]] for the
|
||||||
[[sprint-my-sea-iter-6c-may20]] for the rationale."""
|
rationale + the user-reported bug it fixes."""
|
||||||
from apps.gameboard.models import MySeaDraw
|
from apps.gameboard.models import MySeaDraw
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
self._save_empty_hand_draw()
|
draw = self._save_empty_hand_draw()
|
||||||
free_count_before = Token.objects.filter(
|
free_count_before = Token.objects.filter(
|
||||||
user=self.gamer, token_type=Token.FREE,
|
user=self.gamer, token_type=Token.FREE,
|
||||||
).count()
|
).count()
|
||||||
@@ -1514,8 +1514,6 @@ class MySeaGatekeeperPageTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
paid_draw.click()
|
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(
|
self.wait_for(
|
||||||
lambda: self.assertIn(
|
lambda: self.assertIn(
|
||||||
"/gameboard/my-sea/?phase=picker", self.browser.current_url
|
"/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(),
|
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
|
||||||
free_count_before - 1,
|
free_count_before - 1,
|
||||||
)
|
)
|
||||||
# Row dropped — `active_draw is None` is the signal that lets
|
# Row preserved + paid_through_at stamped (sticky PAID DRAW state).
|
||||||
# the my_sea view honour the `?phase=picker` override.
|
draw.refresh_from_db()
|
||||||
self.assertFalse(
|
self.assertIsNotNone(draw.paid_through_at,
|
||||||
MySeaDraw.objects.filter(user=self.gamer).exists()
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -479,9 +479,13 @@ body.page-billposts {
|
|||||||
width: var(--applet-card-w);
|
width: var(--applet-card-w);
|
||||||
aspect-ratio: 5 / 8;
|
aspect-ratio: 5 / 8;
|
||||||
border-radius: 0.4rem;
|
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);
|
background: rgba(var(--priUser), 1);
|
||||||
border: 0.12rem solid rgba(var(--secUser), 0.6);
|
border: 0.12rem solid rgba(var(--secUser), 0.6);
|
||||||
color: rgba(var(--secUser), 1);
|
color: rgba(var(--terUser), 1);
|
||||||
padding: 0.35rem;
|
padding: 0.35rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -513,39 +517,55 @@ body.page-billposts {
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card face — name + arcana stacked, centred in the remaining
|
// Card face — qualifier + title + arcana stacked, centred in the
|
||||||
// vertical space between the two corners. `flex: 1` lets the
|
// remaining vertical space between the two corners. `gap: 0` so
|
||||||
// face absorb whatever's left after the absolute-positioned
|
// qualifier sits directly above the title at the title's own
|
||||||
// corners.
|
// line-height; `.fan-card-arcana` carries its own margin-top to
|
||||||
|
// restore breathing room between title block and arcana label.
|
||||||
.fan-card-face {
|
.fan-card-face {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.2rem;
|
gap: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 0.2rem;
|
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 {
|
.fan-card-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: calc(var(--applet-card-w) * 0.11);
|
font-size: calc(var(--applet-card-w) * 0.11);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
color: rgba(var(--quiUser), 1);
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.fan-card-qualifier:empty { display: none; }
|
||||||
|
|
||||||
.fan-card-arcana {
|
.fan-card-arcana {
|
||||||
margin: 0;
|
margin: calc(var(--applet-card-w) * 0.05) 0 0;
|
||||||
font-size: calc(var(--applet-card-w) * 0.075);
|
font-size: calc(var(--applet-card-w) * 0.075);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
opacity: 0.6;
|
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 +
|
// 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 {
|
.my-sign-applet-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -226,6 +226,9 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
width: var(--sig-card-w);
|
width: var(--sig-card-w);
|
||||||
height: calc(var(--sig-card-w) * 8 / 5);
|
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);
|
background: rgba(var(--priUser), 1);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
border: 0.1rem solid rgba(var(--terUser), 0.15);
|
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 {
|
.fan-card {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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 {
|
.sea-stage-content .sea-stat-block {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: var(--sig-card-w, 140px);
|
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) {
|
@media (orientation: landscape) {
|
||||||
html.sea-open body .container .navbar,
|
html.sea-open body .container .navbar,
|
||||||
html.sea-open body #id_footer {
|
html.sea-open body #id_footer {
|
||||||
|
|||||||
@@ -194,8 +194,6 @@ body.page-gameboard {
|
|||||||
// FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
|
// FREE DRAW btn — centered in the hex, mirrors SCAN SIGN's 2-line
|
||||||
// font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
|
// font sizing so "FREE/DRAW" sits cleanly inside the 4rem circle.
|
||||||
#id_draw_sea_btn {
|
#id_draw_sea_btn {
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,17 +653,26 @@ body.page-gameboard {
|
|||||||
transform: rotate(180deg);
|
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 {
|
.fan-card-face {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.2rem;
|
gap: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 0.2rem;
|
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 {
|
.fan-card-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: calc(var(--slot-w) * 0.105);
|
font-size: calc(var(--slot-w) * 0.105);
|
||||||
@@ -673,9 +680,10 @@ body.page-gameboard {
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
.fan-card-qualifier:empty { display: none; }
|
||||||
|
|
||||||
.fan-card-arcana {
|
.fan-card-arcana {
|
||||||
margin: 0;
|
margin: calc(var(--slot-w) * 0.05) 0 0;
|
||||||
font-size: calc(var(--slot-w) * 0.07);
|
font-size: calc(var(--slot-w) * 0.07);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -685,35 +693,31 @@ body.page-gameboard {
|
|||||||
|
|
||||||
// Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity`
|
// Filled slot polarity — mirrors `.sea-card-slot--gravity` / `--levity`
|
||||||
// in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text;
|
// in `_card-deck.scss:1332-1341`. Gravity = priUser bg + quiUser text;
|
||||||
// levity = inverted (secUser bg + priUser text). Explicit
|
// levity = inverted (secUser bg + priUser text). `.fan-card-name`,
|
||||||
// `.fan-card-name { color: ... }` override is required: the global
|
// `.fan-card-qualifier`, `.fan-card-corner` + `.fan-card-arcana` all
|
||||||
// `.fan-card-name` rule in `_card-deck.scss:569-570` hardcodes
|
// pin `color: inherit` so they pick up the slot's polarity color
|
||||||
// --quiUser, which is invisible on the levity --secUser bg (both
|
// uniformly — the global `.fan-card-face .fan-card-name { color:
|
||||||
// light variants). Setting it back to --priUser here restores
|
// --terUser }` rule in `_card-deck.scss:376-383` loads AFTER gameboard
|
||||||
// contrast. Corner-rank + arcana inherit from the slot's `color`
|
// (per `core.scss` import order) and otherwise wins at matching 0,2,0
|
||||||
// (no global override) so they follow polarity automatically.
|
// 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 {
|
.my-sea-slot--filled.my-sea-slot--gravity {
|
||||||
background: rgba(var(--priUser), 1);
|
background: rgba(var(--priUser), 1);
|
||||||
color: rgba(var(--quiUser), 1);
|
color: rgba(var(--quiUser), 1);
|
||||||
border-color: rgba(var(--secUser), 0.6);
|
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 {
|
.my-sea-slot--filled.my-sea-slot--levity {
|
||||||
background: rgba(var(--secUser), 1);
|
background: rgba(var(--secUser), 1);
|
||||||
color: rgba(var(--priUser), 1);
|
color: rgba(var(--priUser), 1);
|
||||||
border-color: rgba(var(--priUser), 1);
|
border-color: rgba(var(--priUser), 1);
|
||||||
// `.fan-card-corner` carries a global `color: rgba(var(--secUser),
|
.fan-card-corner { color: inherit; }
|
||||||
// 0.75)` rule in `_card-deck.scss:312-319` that out-specifics the
|
.fan-card-qualifier { color: inherit; }
|
||||||
// slot's inherited color (specificity 0,1,0 wins over inheritance).
|
.fan-card-name { color: inherit; }
|
||||||
// On the levity --secUser bg this paints the corner-rank + suit-
|
.fan-card-arcana { color: inherit; opacity: 0.7; }
|
||||||
// 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); }
|
|
||||||
}
|
}
|
||||||
.my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); }
|
.my-sea-slot--filled.my-sea-slot--reversed { transform: rotate(180deg); }
|
||||||
|
|
||||||
@@ -732,13 +736,15 @@ body.page-gameboard {
|
|||||||
border-width: 0.15rem !important;
|
border-width: 0.15rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label — pulled tight against the slot's bottom border + vertically
|
// Label — sibling of the slot inside the wrap, sits BELOW the slot
|
||||||
// stretched via `scaleY(1.2)` to match the my_sea.html picker's
|
// box (mirrors the my_sea.html picker's `.sea-pos-label` placement).
|
||||||
// `.sea-pos-label` typography (re-appropriated from `.sea-stack-name`
|
// `margin-top: -0.15rem` crosses the slot's bottom border so the
|
||||||
// in `_card-deck.scss:1671-1684`). Negative margin-top crosses the
|
// label's top edge sits flush against it; `position: relative;
|
||||||
// 0.12rem border so the label's top edge overlaps the bottom edge of
|
// z-index: 2` keeps the label text rendering ATOP the slot's bottom
|
||||||
// the slot, per the user-locked spec ("practically overlapping").
|
// border (dotted for empty slots, solid for filled).
|
||||||
.my-sea-slot-label {
|
.my-sea-slot-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
margin-top: -0.15rem;
|
margin-top: -0.15rem;
|
||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -749,7 +755,7 @@ body.page-gameboard {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
transform: scaleY(1.4);
|
transform: scaleY(1.3);
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
// `.my-sea-slot-label--empty` intentionally has NO per-state recolor
|
// `.my-sea-slot-label--empty` intentionally has NO per-state recolor
|
||||||
|
|||||||
@@ -3,21 +3,42 @@
|
|||||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||||
>
|
>
|
||||||
<h2><a href="{% url 'billboard:my_sign' %}">My Sign</a></h2>
|
<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 %}
|
{% if request.user.significator %}
|
||||||
{% with card=request.user.significator %}
|
{% with card=request.user.significator %}
|
||||||
{# Mirrors the my_sign.html `.sig-stage-card` layout — corner #}
|
{# Mirrors the my_sign.html `.sig-stage-card` layout — corner #}
|
||||||
{# top-left, full name in the face, polarity-reversed mirror #}
|
{# top-left, name + polarity qualifier in the face, mirror #}
|
||||||
{# at the bottom (pre-rotated). Sized to fill the applet's #}
|
{# corner bottom-right (pre-rotated). Sized to fill the #}
|
||||||
{# vertical aperture via container queries in `_billboard.scss`. #}
|
{# applet's vertical aperture via container queries in #}
|
||||||
<div class="my-sign-applet-card{% if request.user.significator_reversed %} stage-card--reversed{% endif %}"
|
{# `_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 }}">
|
data-card-id="{{ card.id }}">
|
||||||
<div class="fan-card-corner fan-card-corner--tl">
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="fan-card-face">
|
<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>
|
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fan-card-corner fan-card-corner--br">
|
<div class="fan-card-corner fan-card-corner--br">
|
||||||
@@ -26,28 +47,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Stat block — same shape as my_sign.html's `.sig-stat-block` #}
|
{# Stat block — same shape as my_sign.html's `.sig-stat-block` #}
|
||||||
{# (Emanation/Reversal face label + keyword list) but no SPIN #}
|
{# (Emanation face label + keyword list) but no SPIN/FYI btns #}
|
||||||
{# or FYI buttons since the applet is a read-only preview. The #}
|
{# since the applet is a read-only preview. Saved sigs persist #}
|
||||||
{# face shown is keyed off significator_reversed: True → #}
|
{# only the polarity axis (FLIP), never the orientation axis #}
|
||||||
{# reversal keywords (labelled "Reversal"), False → upright #}
|
{# (SPIN), so always render the upright/emanation face. #}
|
||||||
{# (labelled "Emanation"). Mirrors the FYI panel populated by #}
|
|
||||||
{# `StageCard.populateKeywords` in my_sign.html's JS init. #}
|
|
||||||
<div class="my-sign-applet-stat-block">
|
<div class="my-sign-applet-stat-block">
|
||||||
{% if request.user.significator_reversed %}
|
<p class="stat-face-label">Emanation</p>
|
||||||
<p class="stat-face-label">Reversal</p>
|
<ul class="stat-keywords">
|
||||||
<ul class="stat-keywords">
|
{% for kw in card.keywords_upright %}
|
||||||
{% for kw in card.keywords_reversed %}
|
<li>{{ kw }}</li>
|
||||||
<li>{{ kw }}</li>
|
{% endfor %}
|
||||||
{% endfor %}
|
</ul>
|
||||||
</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>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -139,6 +139,7 @@
|
|||||||
data-levity-qualifier="{{ card.levity_qualifier }}"
|
data-levity-qualifier="{{ card.levity_qualifier }}"
|
||||||
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
||||||
data-reversal-qualifier="{{ card.reversal_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-levity-emanation="{{ card.levity_emanation }}"
|
||||||
data-gravity-emanation="{{ card.gravity_emanation }}"
|
data-gravity-emanation="{{ card.gravity_emanation }}"
|
||||||
data-levity-reversal="{{ card.levity_reversal }}"
|
data-levity-reversal="{{ card.levity_reversal }}"
|
||||||
@@ -379,22 +380,18 @@
|
|||||||
|
|
||||||
// On-load: if user has a saved sig, populate the stage preview AND
|
// 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
|
// reveal the stat block (via .sig-stage--frozen) so the saved card
|
||||||
// appears alongside its emanation/reversal keywords — the page is
|
// appears alongside its emanation keywords — the page is read-only
|
||||||
// read-only on landing while a sig is committed (hex is server-side
|
// on landing while a sig is committed (hex is server-side hidden,
|
||||||
// hidden, DEL is the only action). The picker grid stays hidden
|
// DEL is the only action). The picker grid stays hidden until SCAN
|
||||||
// until SCAN SIGN — but SCAN SIGN itself is gone in this state, so
|
// SIGN — but SCAN SIGN itself is gone in this state, so the user
|
||||||
// the user must DEL → reload to ever re-enter picker phase.
|
// must DEL → reload to ever re-enter picker phase.
|
||||||
//
|
//
|
||||||
// If the saved sig is reversed, also call _toggleOrientation() once
|
// `significator_reversed` is the POLARITY axis (reversed=True ↔
|
||||||
// so the stage card visually rotates 180° + the stat block swaps to
|
// levity), already reflected in `data-polarity` on the page wrapper
|
||||||
// its reversal face. The server-side `data-polarity` attribute on
|
// and threaded into `_polarity()` so `_populateStage` paints the
|
||||||
// .my-sign-page already reflects the reversed flag (drives polarity-
|
// correct levity/gravity qualifier on the upright face. The SPIN
|
||||||
// themed colors via the [data-polarity=...] CSS rules) but the
|
// axis (.stage-card--reversed rotation) is preview-only and is NOT
|
||||||
// visual rotation lives in the `stage-card--reversed` class which
|
// persisted — saved sigs always render upright in their polarity.
|
||||||
// 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.
|
|
||||||
var savedId = pageEl.dataset.currentCardId;
|
var savedId = pageEl.dataset.currentCardId;
|
||||||
if (savedId && grid) {
|
if (savedId && grid) {
|
||||||
var savedCardEl = grid.querySelector(
|
var savedCardEl = grid.querySelector(
|
||||||
@@ -402,9 +399,6 @@
|
|||||||
if (savedCardEl) {
|
if (savedCardEl) {
|
||||||
_populateStage(savedCardEl);
|
_populateStage(savedCardEl);
|
||||||
stage.classList.add('sig-stage--frozen');
|
stage.classList.add('sig-stage--frozen');
|
||||||
if (revInput.value === '1') {
|
|
||||||
_toggleOrientation();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
<div class="my-sea-slot-wrap">
|
<div class="my-sea-slot-wrap">
|
||||||
{# Mirrors the my_sign.html `.sig-stage-card` layout — #}
|
{# Mirrors the my_sign.html `.sig-stage-card` layout — #}
|
||||||
{# corner top-left, face w. name + arcana, mirror corner #}
|
{# corner top-left, face w. name + arcana, mirror corner #}
|
||||||
{# bottom-right. Sized to fill the applet height via #}
|
{# bottom-right. Label is a SIBLING of the slot inside #}
|
||||||
{# container queries in `_gameboard.scss`. #}
|
{# 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 %}"
|
<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-position="{{ slot.position }}"
|
||||||
data-card-id="{{ slot.card.id }}">
|
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 %}
|
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="fan-card-face">
|
<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>
|
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="fan-card-corner fan-card-corner--br">
|
<div class="fan-card-corner fan-card-corner--br">
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
|
|||||||
data-levity-qualifier="{{ card.levity_qualifier }}"
|
data-levity-qualifier="{{ card.levity_qualifier }}"
|
||||||
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
||||||
data-reversal-qualifier="{{ card.reversal_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-levity-emanation="{{ card.levity_emanation }}"
|
||||||
data-gravity-emanation="{{ card.gravity_emanation }}"
|
data-gravity-emanation="{{ card.gravity_emanation }}"
|
||||||
data-levity-reversal="{{ card.levity_reversal }}"
|
data-levity-reversal="{{ card.levity_reversal }}"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
data-levity-qualifier="{{ card.levity_qualifier }}"
|
data-levity-qualifier="{{ card.levity_qualifier }}"
|
||||||
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
data-gravity-qualifier="{{ card.gravity_qualifier }}"
|
||||||
data-reversal-qualifier="{{ card.reversal_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-levity-emanation="{{ card.levity_emanation }}"
|
||||||
data-gravity-emanation="{{ card.gravity_emanation }}"
|
data-gravity-emanation="{{ card.gravity_emanation }}"
|
||||||
data-levity-reversal="{{ card.levity_reversal }}"
|
data-levity-reversal="{{ card.levity_reversal }}"
|
||||||
|
|||||||
@@ -52,14 +52,21 @@
|
|||||||
<div class="table-hex-border">
|
<div class="table-hex-border">
|
||||||
<div class="table-hex">
|
<div class="table-hex">
|
||||||
<div class="table-center">
|
<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">
|
<form method="POST" action="{% url 'my_sea_paid_draw' %}" style="display:contents">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
id="id_my_sea_paid_draw_btn"
|
id="id_my_sea_paid_draw_btn"
|
||||||
class="btn btn-primary">PAID<br>DRAW</button>
|
class="btn btn-primary">PAID<br>DRAW</button>
|
||||||
</form>
|
</form>
|
||||||
{% elif quota_spent %}
|
{% elif show_gate_view %}
|
||||||
<button id="id_my_sea_gate_view_btn"
|
<button id="id_my_sea_gate_view_btn"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user