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:
@@ -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
|
||||
"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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user