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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-23 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gameboard', '0002_myseadraw_deposit_reserved_at_and_more'),
]
operations = [
migrations.AddField(
model_name='myseadraw',
name='paid_through_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -68,6 +68,7 @@ def latest_draw_slots(user):
"card": <TarotCard>, # None if not yet drawn
"reversed": bool, # False if not yet drawn
"polarity": "gravity", # "" if not yet drawn
"face": dict | None, # applet_face() payload — see TarotCard
}
Empty list = no active draw OR active draw w. empty hand.
"""
@@ -89,12 +90,17 @@ def latest_draw_slots(user):
slots = []
for pos in order:
entry = by_position.get(pos)
card = cards_by_id.get(entry["card_id"]) if entry else None
reversed_flag = entry.get("reversed", False) if entry else False
polarity = entry.get("polarity", "gravity") if entry else ""
slots.append({
"position": pos,
"label": labels.get(pos, ""),
"card": cards_by_id.get(entry["card_id"]) if entry else None,
"reversed": entry.get("reversed", False) if entry else False,
"polarity": entry.get("polarity", "") if entry else "",
"card": card,
"reversed": reversed_flag,
"polarity": polarity,
"face": (card.applet_face(polarity or "gravity", reversed_flag)
if card else None),
})
return slots
@@ -144,6 +150,16 @@ class MySeaDraw(models.Model):
# + `debit_my_sea_token` for the priority chain + per-type rules.
deposit_token_id = models.IntegerField(null=True, blank=True)
deposit_reserved_at = models.DateTimeField(null=True, blank=True)
# PAID DRAW credit marker — set when `my_sea_paid_draw` commits the
# deposited token. Stays sticky until the credit is consumed by the
# first card-draw (cleared in `my_sea_lock`) OR the row expires.
# Drives the landing-button state: a row w. `paid_through_at` set +
# `hand=[]` renders the PAID DRAW button (navigates to picker, no new
# token spent), so a user who pays + navigates away before drawing
# still sees their paid state preserved (user-reported bug 2026-05-23
# — without this field, the row was deleted at commit time + the
# landing fell through to FREE DRAW on next page load).
paid_through_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
@@ -274,7 +290,12 @@ def active_draw_for(user):
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
the DB doesn't accumulate one row per user per day. The user-spec
'auto-delete all draws after 24hrs, whether or not the user has
deleted them' (2026-05-20) lands here w. no scheduler required."""
deleted them' (2026-05-20) lands here w. no scheduler required.
Cooldown for FREE-DRAW-button rendering is separately tracked at the
User level (`User.last_free_draw_at`) — see [[feedback-cooldown-
anchored-to-free-draw]]. This function is purely about row TTL.
"""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
return MySeaDraw.objects.filter(

View File

@@ -110,6 +110,11 @@ var GameKit = (function () {
// and per-polarity qualifier rendering stay consistent with the data).
StageCard.populateCard(cardEl, card, _polarity);
cardEl.dataset.polarity = _polarity;
// Mirror polarity on `.tarot-fan-wrap` so the fan-stage-block can
// invert its bg per the sig convention (user-spec 2026-05-23: stat
// block always carries the opposite-polarity color of its adjacent
// card). See `_card-deck.scss:.tarot-fan-wrap[data-polarity]`.
if (fanWrap) fanWrap.dataset.polarity = _polarity;
StageCard.populateKeywords(stageBlock, card.keywords_upright, card.keywords_reversed, {
uprightSel: '#id_fan_stat_upright',
@@ -156,6 +161,8 @@ var GameKit = (function () {
var card = StageCard.fromDataset(active);
StageCard.populateCard(active, card, _polarity);
active.dataset.polarity = _polarity;
// Mirror onto the wrap so the stage block re-invertds in lockstep.
if (fanWrap) fanWrap.dataset.polarity = _polarity;
}, 250);
// Clear the in-flight flag at animation end. Using setTimeout (not
// anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests.

View File

@@ -195,6 +195,113 @@ class GameboardViewTest(TestCase):
["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"],
)
def test_my_sea_applet_renders_polarity_qualifier_per_slot(self):
"""Each filled slot carries a `.fan-card-qualifier` whose text is
the polarity qualifier for the slot's polarity (upright) or the
reversal_qualifier (reversed). User-reported 2026-05-23: applet
was rendering only the title, no qualifier."""
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
sig_pile = personal_sig_cards(self.user)
self.user.significator = sig_pile[0]
self.user.save()
# Pick a middle court card (Queen of Crowns) — has levity_qualifier
# "Elevated", gravity_qualifier "Graven", reversal_qualifier "Vacant".
queen_of_crowns = TarotCard.objects.filter(
arcana="MIDDLE", suit="CROWNS", number=13,
).first()
MySeaDraw.objects.create(
user=self.user,
spread="situation-action-outcome",
hand=[
{"position": "lay", "card_id": queen_of_crowns.id,
"reversed": False, "polarity": "levity"},
],
significator_id=self.user.significator_id,
)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
quals = parsed.cssselect(
"#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
)
self.assertEqual(len(quals), 1)
self.assertEqual(quals[0].text_content().strip(), "Elevated")
def test_my_sea_applet_major_renders_title_comma_qualifier_below(self):
"""Major Arcana w. a qualifier (trump 9 — 'Erasing Personal
History' + 'Sublimating') renders as 'Title,' / 'Qualifier' per
the page's stage card convention (`stage-card.js:141-143`). The
qualifier `<p>` lands AFTER the name `<p>` in DOM order."""
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
sig_pile = personal_sig_cards(self.user)
self.user.significator = sig_pile[0]
self.user.save()
trump_9 = TarotCard.objects.filter(
arcana="MAJOR", number=9, deck_variant__slug="earthman",
).first()
self.assertIsNotNone(trump_9,
"seed migration 0007 should produce Earthman trump 9 "
"('Erasing Personal History') w. levity_qualifier='Sublimating'")
MySeaDraw.objects.create(
user=self.user,
spread="situation-action-outcome",
hand=[
{"position": "lay", "card_id": trump_9.id,
"reversed": False, "polarity": "levity"},
],
significator_id=self.user.significator_id,
)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
face = parsed.cssselect(
"#id_applet_my_sea .my-sea-slot--filled .fan-card-face"
)[0]
# DOM order: name → qualifier (NOT qualifier → name).
children = [
el for el in face
if el.tag == "p"
and any(
cls in (el.get("class") or "")
for cls in ("fan-card-name", "fan-card-qualifier")
)
]
self.assertEqual(len(children), 2)
self.assertIn("fan-card-name", children[0].get("class"))
self.assertIn("fan-card-qualifier", children[1].get("class"))
# Title carries a trailing comma; qualifier is "Sublimating" (levity).
self.assertTrue(
children[0].text_content().strip().endswith(","),
f"expected trailing comma on Major title, got "
f"{children[0].text_content()!r}",
)
self.assertEqual(children[1].text_content().strip(), "Sublimating")
def test_my_sea_applet_renders_reversal_qualifier_for_reversed_slot(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
sig_pile = personal_sig_cards(self.user)
self.user.significator = sig_pile[0]
self.user.save()
queen_of_crowns = TarotCard.objects.filter(
arcana="MIDDLE", suit="CROWNS", number=13,
).first()
MySeaDraw.objects.create(
user=self.user,
spread="situation-action-outcome",
hand=[
{"position": "lay", "card_id": queen_of_crowns.id,
"reversed": True, "polarity": "gravity"},
],
significator_id=self.user.significator_id,
)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
quals = parsed.cssselect(
"#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
)
self.assertEqual(quals[0].text_content().strip(), "Vacant")
def test_gameboard_shows_game_kit(self):
[_] = self.parsed.cssselect("#id_game_kit")
@@ -1123,6 +1230,79 @@ class MySeaLockHandViewTest(TestCase):
parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed)
def test_lock_post_first_card_sets_user_last_free_draw_at(self):
# User-spec 2026-05-23: free-draw cooldown is anchored to User.
# last_free_draw_at, set on the first-card-of-cycle lock so the
# next FREE DRAW unlocks 24h later regardless of any intervening
# PAID DRAWs. Fresh user (no prior cooldown) → SET.
import json
before = timezone.now()
self.assertIsNone(self.user.last_free_draw_at,
"precondition: fresh user has no prior free draw")
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertIsNotNone(self.user.last_free_draw_at)
self.assertGreaterEqual(self.user.last_free_draw_at, before)
def test_lock_post_during_cooldown_does_not_reset_last_free_draw_at(self):
# User is mid-cooldown (last_free_draw_at set to 6h ago). A
# subsequent /lock POST (e.g. a paid draw committing its first
# card) must NOT bump last_free_draw_at — the cooldown stays
# anchored to the original FREE DRAW per user-spec 2026-05-23.
import json
from datetime import timedelta
from apps.gameboard.models import MySeaDraw
original_anchor = timezone.now() - timedelta(hours=6)
self.user.last_free_draw_at = original_anchor
self.user.save(update_fields=["last_free_draw_at"])
# Seed an existing row in a paid-through state (no hand yet).
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
paid_through_at=timezone.now(),
)
# Now lock the first paid card.
self.client.post(
self.url, data=json.dumps(self._build_payload(hand=[{
"position": "lay", "card_id": self.target.id,
"reversed": False, "polarity": "gravity",
}])),
content_type="application/json",
)
self.user.refresh_from_db()
# last_free_draw_at unchanged — within 1s of original anchor.
delta = abs((self.user.last_free_draw_at - original_anchor).total_seconds())
self.assertLess(delta, 1.0)
def test_lock_post_first_paid_card_consumes_paid_through_credit(self):
# User-spec 2026-05-23: paid_through credit is one-shot. The
# first card drawn after PAID DRAW commit clears `paid_through_
# at` so the next redraw requires a fresh gatekeeper deposit.
import json
from apps.gameboard.models import MySeaDraw
self.user.last_free_draw_at = timezone.now() - timezone.timedelta(hours=6)
self.user.save(update_fields=["last_free_draw_at"])
row = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
paid_through_at=timezone.now(),
)
self.client.post(
self.url, data=json.dumps(self._build_payload(hand=[{
"position": "lay", "card_id": self.target.id,
"reversed": False, "polarity": "gravity",
}])),
content_type="application/json",
)
row.refresh_from_db()
self.assertIsNone(row.paid_through_at,
"first card of paid session must consume the paid_through credit")
self.assertEqual(len(row.hand), 1)
def test_lock_post_within_quota_upserts_same_row(self):
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
# Second POST w. same spread updates the existing row's hand
@@ -1722,13 +1902,28 @@ class MySeaPaidDrawViewTest(TestCase):
self.client.post(self.url)
self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())
def test_paid_draw_deletes_active_draw_row(self):
# User-spec 2026-05-20: PAID DRAW commits the token + drops the row
# entirely so the user returns to a fresh "able-to-draw-now" state
# (instead of the buggy "row preserved → GATE VIEW loop" semantics).
def test_paid_draw_preserves_row_and_sets_paid_through_at(self):
# User-spec 2026-05-23 (replaces the 2026-05-20 "delete row"
# spec): PAID DRAW preserves the row + sets `paid_through_at`
# so the landing PAID DRAW button stays visible across navigation
# cycles. Without this, the user who pays but doesn't immediately
# draw cards sees the button revert to FREE DRAW on next page
# load (the reported regression). `deposit_token_id` /
# `deposit_reserved_at` clear (token spent, no longer reserved);
# `hand` clears (fresh start per user-confirmed semantics).
from apps.gameboard.models import MySeaDraw
before = timezone.now()
self.client.post(self.url)
self.assertFalse(MySeaDraw.objects.filter(pk=self.draw.pk).exists())
self.draw.refresh_from_db()
self.assertTrue(MySeaDraw.objects.filter(pk=self.draw.pk).exists(),
"PAID DRAW must preserve the row (was previously deleted)")
self.assertIsNone(self.draw.deposit_token_id)
self.assertIsNone(self.draw.deposit_reserved_at)
self.assertIsNotNone(self.draw.paid_through_at,
"PAID DRAW must stamp paid_through_at on commit")
self.assertGreaterEqual(self.draw.paid_through_at, before)
self.assertEqual(self.draw.hand, [],
"PAID DRAW must clear the hand (fresh paid session)")
def test_paid_draw_redirects_to_my_sea_with_phase_picker(self):
# User-spec 2026-05-20: drop the user directly into the picker
@@ -1772,12 +1967,203 @@ class MySeaPaidDrawViewTest(TestCase):
self.assertEqual(response.status_code, 302)
class MySeaCooldownAnchoredToFreeDrawTest(TestCase):
"""User-spec 2026-05-23: the 24h free-draw cooldown is anchored to the
user's last TOKENLESS first-card-draw (`User.last_free_draw_at`), NOT
to any subsequent paid draws. A paid draw in the middle of the cycle
must NOT push the cooldown forward — the next FREE DRAW unlocks at
free-draw + 24h regardless of any interim paid activity.
Also pins the "sticky PAID DRAW button" UX: after PAID DRAW commits,
the row carries a `paid_through_at` credit until the first card of
the paid session lands. During that window, any navigation back to
/gameboard/my-sea/ keeps the landing button labelled PAID DRAW (it
used to revert to FREE DRAW because the row was deleted at commit
time — the user-reported bug)."""
def setUp(self):
from apps.epic.models import personal_sig_cards
from apps.lyric.models import Token
self.user = User.objects.create(email="anchor@test.io")
self.user.tokens.all().delete()
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
# FREE token for the paid-draw step.
self.free_tok = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=30),
)
def _seed_used_free_draw(self, when=None):
"""Simulate a completed FREE DRAW + DEL: row exists w. hand=[],
no deposit, `User.last_free_draw_at` anchored at `when`."""
from apps.gameboard.models import MySeaDraw
when = when or timezone.now()
self.user.last_free_draw_at = when
self.user.save(update_fields=["last_free_draw_at"])
return MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
created_at=when,
)
def test_paid_draw_does_not_reset_user_last_free_draw_at(self):
original_anchor = timezone.now() - timedelta(hours=6)
draw = self._seed_used_free_draw(when=original_anchor)
# Deposit + commit a token via the PAID DRAW endpoint.
draw.deposit_token_id = self.free_tok.pk
draw.deposit_reserved_at = timezone.now()
draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
self.client.post(reverse("my_sea_paid_draw"))
self.user.refresh_from_db()
# User.last_free_draw_at stays at the original anchor
# (within 1s tolerance for timestamp wobble).
delta = abs(
(self.user.last_free_draw_at - original_anchor).total_seconds()
)
self.assertLess(delta, 1.0,
"PAID DRAW must NOT touch User.last_free_draw_at — the "
"cooldown stays anchored to the original FREE DRAW per "
"user-spec 2026-05-23")
def test_brief_next_free_draw_at_uses_user_anchor_not_paid_row(self):
# The view passes `next_free_draw_at` to the template as ISO
# — the Brief script in my_sea.html surfaces this directly.
# Anchor: user's last_free_draw_at + 24h, NOT row.created_at
# + 24h (which after PAID DRAW would point 24h past the paid
# commit, not 24h past the free draw).
original_anchor = timezone.now() - timedelta(hours=6)
self._seed_used_free_draw(when=original_anchor)
# The active row's `created_at` matches the seed time (free
# draw moment). Now simulate a "row created LATER than anchor"
# state by bumping created_at forward — this is what would
# happen if the row were re-created at PAID DRAW time under
# the old buggy delete-on-commit semantics.
from apps.gameboard.models import MySeaDraw
row = MySeaDraw.objects.get(user=self.user)
row.created_at = timezone.now() # "PAID DRAW just created me"
row.save(update_fields=["created_at"])
response = self.client.get(reverse("my_sea"))
# The user's next_free_draw_at = anchor + 24h, NOT row.created_at
# + 24h. Differs by ~6h; check that the rendered ISO matches the
# user-level anchor (truncated to date+hour for stability).
expected_user_iso = (
original_anchor + timedelta(hours=24)
).isoformat()[:13] # "YYYY-MM-DDTHH" — date + hour
self.assertIn(expected_user_iso, response.content.decode())
def test_paid_draw_commit_makes_landing_show_paid_draw_btn(self):
# End-to-end of the user-reported bug: deposit → PAID DRAW commit
# → navigate to /gameboard/my-sea/ → landing must show PAID DRAW
# (NOT FREE DRAW, NOT GATE VIEW). Pre-fix: row was deleted at
# commit time + landing fell through to FREE DRAW.
from apps.gameboard.models import MySeaDraw
draw = self._seed_used_free_draw()
draw.deposit_token_id = self.free_tok.pk
draw.deposit_reserved_at = timezone.now()
draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
# Commit the paid draw.
self.client.post(reverse("my_sea_paid_draw"))
# Simulate user navigating away + back: re-render my_sea.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_my_sea_paid_draw_btn"',
msg_prefix="post-PAID-DRAW navigation must keep PAID DRAW btn")
self.assertNotContains(response, 'id="id_draw_sea_btn"',
msg_prefix="FREE DRAW btn must NOT show after PAID DRAW commit")
self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"',
msg_prefix="GATE VIEW btn must NOT show while paid-through is set")
def test_paid_draw_btn_post_with_paid_through_redirects_to_picker(self):
# After commit, the PAID DRAW button on the landing should
# route the user back to the picker (via ?phase=picker) without
# consuming another token.
from apps.gameboard.models import MySeaDraw
draw = self._seed_used_free_draw()
draw.paid_through_at = timezone.now()
draw.save(update_fields=["paid_through_at"])
free_count_before = self.user.tokens.filter(
token_type=self.free_tok.token_type
).count()
response = self.client.post(reverse("my_sea_paid_draw"))
self.assertEqual(response.status_code, 302)
self.assertIn("phase=picker", response["Location"])
# No token consumed — the paid-through credit covers this.
self.assertEqual(
self.user.tokens.filter(
token_type=self.free_tok.token_type
).count(),
free_count_before,
)
def test_first_card_after_paid_draw_consumes_paid_through_credit(self):
# User-spec 2026-05-23 follow-up: paid-through is one-shot. Once
# the user draws their first card of the paid session, the
# credit is consumed → next redraw needs a fresh deposit.
import json
from apps.gameboard.models import MySeaDraw
from apps.epic.models import TarotCard
draw = self._seed_used_free_draw()
draw.paid_through_at = timezone.now()
draw.save(update_fields=["paid_through_at"])
card = TarotCard.objects.exclude(id=self.target.id).first()
self.client.post(
reverse("my_sea_lock"),
data=json.dumps({
"spread": "situation-action-outcome",
"hand": [{
"position": "lay", "card_id": card.id,
"reversed": False, "polarity": "gravity",
}],
}),
content_type="application/json",
)
draw.refresh_from_db()
self.assertIsNone(draw.paid_through_at,
"first card of paid session consumes the paid-through credit")
class UserFreeDrawCooldownPropertyTest(TestCase):
"""`User.free_draw_cooldown_active` + `User.next_free_draw_at`
helpers. The cooldown is sticky from `last_free_draw_at` (set on the
user's last tokenless first-card-draw) for FREE_DRAW_COOLDOWN_HOURS."""
def setUp(self):
self.user = User.objects.create(email="cooldown@test.io")
def test_no_last_free_draw_at_returns_false(self):
self.assertIsNone(self.user.last_free_draw_at)
self.assertFalse(self.user.free_draw_cooldown_active)
self.assertIsNone(self.user.next_free_draw_at)
def test_recent_last_free_draw_at_returns_true(self):
self.user.last_free_draw_at = timezone.now() - timedelta(hours=6)
self.user.save()
self.assertTrue(self.user.free_draw_cooldown_active)
def test_old_last_free_draw_at_returns_false(self):
self.user.last_free_draw_at = timezone.now() - timedelta(hours=25)
self.user.save()
self.assertFalse(self.user.free_draw_cooldown_active)
def test_next_free_draw_at_is_last_plus_24h(self):
anchor = timezone.now() - timedelta(hours=6)
self.user.last_free_draw_at = anchor
self.user.save()
self.assertEqual(
self.user.next_free_draw_at,
anchor + timedelta(hours=24),
)
class MySeaPhasePickerQueryParamTest(TestCase):
"""Sprint 6 iter 6c — `?phase=picker` query param forces picker phase
when no active_draw row exists (the just-after-PAID-DRAW state).
Without the param, no-active-draw users default to the FREE DRAW
landing. With it, they drop straight into the picker so they can
start drawing immediately (the token they just spent earns this)."""
"""`?phase=picker` query param forces picker phase when the user is
in a paid cycle (post-PAID-DRAW commit, hand still empty). Updated
2026-05-23 — previously the param worked w. no active row (the old
"delete row at PAID DRAW" semantics). Under the new "preserve row +
set paid_through_at" semantics, the row is present + paid_through_at
is set; the picker shows via the param + the paid-through state."""
def setUp(self):
from apps.epic.models import personal_sig_cards
@@ -1792,15 +2178,24 @@ class MySeaPhasePickerQueryParamTest(TestCase):
self.assertContains(response, 'data-phase="landing"')
self.assertContains(response, 'id="id_draw_sea_btn"')
def test_phase_picker_param_forces_picker(self):
def test_phase_picker_param_forces_picker_when_paid_through(self):
# Simulate the just-after-PAID-DRAW state: row exists w. hand=[]
# + paid_through_at set. The ?phase=picker param drops the user
# into the picker rather than the landing.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
paid_through_at=timezone.now(),
)
response = self.client.get(reverse("my_sea") + "?phase=picker")
self.assertContains(response, 'data-phase="picker"')
# Picker IS rendered (no inline style="display:none" on it).
self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"')
def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self):
# Post-DEL state: active row w. empty hand → quota's spent, the
# query param shouldn't bypass GATE VIEW. Landing branch wins.
# Post-DEL state: active row w. empty hand + NO paid-through
# credit → the user still needs to gatekeeper. Landing wins;
# GATE VIEW button shown.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
@@ -1810,6 +2205,23 @@ class MySeaPhasePickerQueryParamTest(TestCase):
self.assertContains(response, 'data-phase="landing"')
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
def test_paid_through_with_empty_hand_renders_paid_draw_btn_not_free(self):
"""User-reported bug 2026-05-23 — after PAID DRAW commit, if the
user navigates away without drawing, the landing button must
stay as PAID DRAW (not revert to FREE DRAW). Preserved-row +
`paid_through_at` is the regression-pinning state."""
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
paid_through_at=timezone.now(),
)
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="landing"')
self.assertContains(response, 'id="id_my_sea_paid_draw_btn"')
self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"')
class SelectMySeaTokenTest(TestCase):
"""Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE

View File

@@ -211,43 +211,56 @@ def my_sea(request):
if active_draw is not None:
default_spread = active_draw.spread
saved_hand = active_draw.hand
next_free_draw_at = active_draw.next_free_draw_at
hand_complete = active_draw.is_hand_complete
hand_empty = active_draw.is_hand_empty
else:
default_spread = "situation-action-outcome"
saved_hand = []
next_free_draw_at = None
hand_complete = False
hand_empty = True
# Picker is the active phase iff the user has a non-empty hand in
# progress (or completed). Empty-hand active draws (post-DEL) fall
# back to the landing — but render GATE VIEW instead of FREE DRAW
# (the daily quota's spent already; landing's primary nav routes to
# the upcoming gatekeeper). New users + post-24h users land on the
# standard FREE DRAW landing.
#
# `?phase=picker` query param (set by PAID DRAW's redirect) forces
# the picker even when active_draw is None — the user just paid a
# token, so drop them straight into the picker rather than making
# them click FREE DRAW first. Only honored when active_draw is None
# (post-PAID-DRAW state); existing rows route through the normal
# logic above so the param can't accidentally bypass a GATE VIEW
# or empty-hand state.
phase_param = request.GET.get("phase") == "picker"
show_picker = (active_draw is not None and not hand_empty) or (
active_draw is None and phase_param
# Brief banner's "next free draw at" — prefer the User's cooldown
# anchor (`User.last_free_draw_at + 24h`, set on the first card of
# the FREE DRAW path; persists across PAID DRAW commits per user-
# spec 2026-05-23). Falls back to the active row's own
# `next_free_draw_at` for legacy rows (or test fixtures that bypass
# `my_sea_lock`).
next_free_draw_at = (
request.user.next_free_draw_at
or (active_draw.next_free_draw_at if active_draw is not None else None)
)
quota_spent = active_draw is not None # any active row = quota committed
# Sprint 6 iter 6b — landing center-btn 3-way + seat-1 persistence.
# `deposit_reserved` toggles the landing primary from GATE VIEW to
# PAID DRAW (one-click commit of the already-deposited token).
# `hand_non_empty` lifts seat 1 to `.seated` server-side so reloads
# don't lose the JS-only animation state.
# Sprint 6 iter 6b + 2026-05-23 fix — landing center-btn state machine.
# The user is "in cooldown" iff a `MySeaDraw` row exists (the row was
# created at first card-draw of the cycle + survives PAID DRAW commit).
# Within cooldown:
#
# deposit reserved (at gatekeeper) → PAID DRAW (commits + picker)
# paid-through credit set → PAID DRAW (navigates + picker)
# neither → GATE VIEW
#
# Outside cooldown (no row): → FREE DRAW
#
# The two PAID DRAW states share one button label so the user sees a
# stable "you're in a paid cycle" cue across navigation — user-
# reported bug 2026-05-23: PAID DRAW used to revert to FREE DRAW
# after the row was deleted at commit time.
deposit_reserved = (
active_draw is not None and active_draw.deposit_token_id is not None
)
paid_through = (
active_draw is not None and active_draw.paid_through_at is not None
)
in_cooldown = active_draw is not None
show_paid_draw = in_cooldown and (deposit_reserved or paid_through)
show_gate_view = in_cooldown and not show_paid_draw
hand_non_empty = active_draw is not None and bool(active_draw.hand)
# Picker is the active phase iff:
# - the user has a non-empty hand in progress / complete, OR
# - `?phase=picker` query param is set AND the user is in a paid
# cycle (deposit reserved OR paid-through credit set) — covers
# the `my_sea_paid_draw` redirect + lets the PAID DRAW landing
# button send the user back to the picker via a GET.
phase_param = request.GET.get("phase") == "picker"
show_picker = hand_non_empty or (phase_param and show_paid_draw)
# Per-position lookup for the template — keyed by the position slug
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
@@ -291,8 +304,10 @@ def my_sea(request):
"next_free_draw_at": next_free_draw_at,
"hand_complete": hand_complete,
"show_picker": show_picker,
"quota_spent": quota_spent,
"show_paid_draw": show_paid_draw,
"show_gate_view": show_gate_view,
"deposit_reserved": deposit_reserved,
"paid_through": paid_through,
"hand_non_empty": hand_non_empty,
"page_class": "page-gameboard page-my-sea",
})
@@ -347,19 +362,41 @@ def my_sea_lock(request):
# first-card moment.
if existing.spread != spread:
return JsonResponse({"error": "spread_mismatch"}, status=409)
# If this row carried a paid-through credit (set by `my_sea_paid_
# draw` at commit time) AND we're transitioning empty→non-empty,
# the credit is being consumed by this draw — clear it so the
# next attempt requires a fresh gatekeeper deposit (user-spec
# 2026-05-23: "each redraw needs a new token").
was_empty = not existing.hand
existing.hand = hand
existing.save(update_fields=["hand"])
update_fields = ["hand"]
if (was_empty and hand
and existing.paid_through_at is not None):
existing.paid_through_at = None
update_fields.append("paid_through_at")
existing.save(update_fields=update_fields)
return JsonResponse({
"ok": True,
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
"next_free_draw_at": (
request.user.next_free_draw_at.isoformat()
if request.user.next_free_draw_at else None
),
"hand_complete": existing.is_hand_complete,
})
# First card draw → quota commit. Create the row.
# First card draw of a fresh cycle (no row exists). If the user's
# free-draw cooldown isn't active, this is a FREE DRAW — anchor the
# 24h cooldown to the User now (NOT to the row's created_at, per
# user-spec 2026-05-23: the cooldown stays put even across PAID
# DRAWs in the same cycle).
sig_id = request.user.significator_id
if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400)
if not request.user.free_draw_cooldown_active:
request.user.last_free_draw_at = timezone.now()
request.user.save(update_fields=["last_free_draw_at"])
draw = MySeaDraw.objects.create(
user=request.user,
spread=spread,
@@ -369,7 +406,10 @@ def my_sea_lock(request):
)
return JsonResponse({
"ok": True,
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
"next_free_draw_at": (
request.user.next_free_draw_at.isoformat()
if request.user.next_free_draw_at else None
),
"hand_complete": draw.is_hand_complete,
})
@@ -471,26 +511,42 @@ def my_sea_refund_token(request):
@login_required(login_url="/")
@require_POST
def my_sea_paid_draw(request):
"""Commit the deposited token + drop the active_draw row so the
user returns to a fresh "able-to-draw-now" state. Without the row,
`quota_spent` resolves to False on the next my-sea render → the
user can draw cards immediately (the token they just spent earns
them this 24h cycle's worth of draws).
"""Commit the deposited token + mark the row as paid-through so the
PAID DRAW button label persists if the user navigates away before
drawing cards (user-reported bug 2026-05-23: PAID DRAW was reverting
to FREE DRAW after one navigation cycle because the row was deleted
at commit time, wiping the cooldown state).
The token is debited via `debit_my_sea_token` (FREE/TITHE consumed;
COIN 24h cooldown + unequipped; PASS no-op). The row is then
deleted (rather than just reset) — user-spec 2026-05-20: keeping
the row but resetting created_at left `quota_spent=True` on the
next view, looping the user back to GATE VIEW. Delete sidesteps
that entirely.
Semantics:
- debit_my_sea_token consumes the deposited token (FREE/TITHE
deleted; COIN: 24h cooldown + unequip; PASS/BAND: no-op).
- `deposit_token_id` + `deposit_reserved_at` cleared (token spent,
no longer reserved).
- `paid_through_at = now` — sticky credit marker. Drives the
landing-button logic in `my_sea` (PAID DRAW button stays so the
user can re-enter the picker without another gatekeeper visit
as long as `hand` stays empty).
- `hand = []` — fresh start per user-spec 2026-05-23 ("clear hand
on PAID DRAW commit").
- `User.last_free_draw_at` is NOT touched. The 24h cooldown stays
anchored to the original FREE DRAW moment (NOT the paid draw).
Redirects to /gameboard/my-sea/?phase=picker so the user lands
directly in the picker (skipping the FREE DRAW landing click).
directly in the picker after the commit.
"""
from django.urls import reverse
from apps.lyric.models import Token
active_draw = active_draw_for(request.user)
if active_draw is None or active_draw.deposit_token_id is None:
if active_draw is None:
return redirect("my_sea")
# Paid-through credit already set (no deposit currently reserved) —
# this is the user clicking PAID DRAW on the landing AFTER an earlier
# commit, to re-enter the picker. No token debit, just route to the
# picker (the `paid_through_at` credit stays until the first card
# lock consumes it in `my_sea_lock`).
if active_draw.deposit_token_id is None:
if active_draw.paid_through_at is not None:
return redirect(reverse("my_sea") + "?phase=picker")
return redirect("my_sea")
token = Token.objects.filter(
pk=active_draw.deposit_token_id, user=request.user,
@@ -503,7 +559,14 @@ def my_sea_paid_draw(request):
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
return redirect("my_sea")
debit_my_sea_token(request.user, token)
active_draw.delete()
active_draw.deposit_token_id = None
active_draw.deposit_reserved_at = None
active_draw.paid_through_at = timezone.now()
active_draw.hand = []
active_draw.save(update_fields=[
"deposit_token_id", "deposit_reserved_at",
"paid_through_at", "hand",
])
return redirect(reverse("my_sea") + "?phase=picker")