Files
python-tdd/src/apps/gameboard/views.py

676 lines
29 KiB
Python
Raw Normal View History

My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
import json
from django.contrib.auth.decorators import login_required
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle
My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + `_select_my_sea_token` / `debit_my_sea_token` helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
from .models import (
feat: My Sea applet dynamic population + lay/leave POSITION_LABELS swap fix + My Sign applet stat-block + Brief-fied sign-gate + --duoUser olive on all four personal-data surfaces. Six visual+structural items batched across the dashboard/billboard/gameboard. (1) **My Sea applet dynamic population.** Applet at `_applet-my-sea.html` was referencing an undefined `latest_draw_cards` template var — fell through to "No draws yet" even when the user had an active draw. New helpers in `apps/gameboard/models.py`: `DRAW_ORDER` + `POSITION_LABELS` constants (Python mirrors of the JS dicts in `my_sea.html:274-293`) + `latest_draw_slots(user)` builder that pairs each spread position w. its drawn card + display label + polarity. Wired through `gameboard()` + `toggle_game_applets()` views as `my_sea_slots`. Applet now renders all spread slots in DRAW_ORDER: filled = `.my-sea-slot--filled.my-sea-slot--{gravity,levity}` w. corner-tl + face (name + arcana) + corner-br (mirror) markup (same shape language as my_sign.html `.sig-stage-card`), empty = `.my-sea-slot--empty` w. `0.15rem dashed rgba(var(--terUser), 1)` border (matches the picker's `.sea-card-slot` style exactly so the applet reads as a true scaled-down twin). Container queries (`container-type: size` on `.my-sea-scroll`) lift `--slot-w` to fill the applet's vertical aperture (`min(100cqi, calc((100cqh - 1rem) * 5 / 8))` carves the label row). Position labels pulled tight against the slot's bottom border (`margin-top: -0.15rem` crosses the border line) + vertically stretched (`transform: scaleY(1.4)` mirroring `.sea-pos-label` in `_card-deck.scss:1671-1684`) — empty-slot labels keep the same `--secUser` ink as filled-slot labels for title cohesion across the row. Horizontal-scroll on multi-card spreads via mousewheel — `bindMySeaWheel()` in `gameboard.js` translates vertical wheel events to `scrollLeft += deltaY` (lifted verbatim from `bindPaletteWheel` in `dashboard.js:7-14`). (2) **lay/leave POSITION_LABELS swap fix.** User caught in the Escape Velocity picker that LEFT slot read "Lay" + BOTTOM slot read "Leave" — opposite of traditional Celtic Cross semantics (LEFT = Behind/past, BOTTOM = Beneath/root). Root cause: POSITION_LABELS for both Waite-Smith + Escape Velocity had `lay`/`leave` slug→label assignments inverted vs the CSS grid's spatial mapping (`_card-deck.scss:1276-1279` puts slug `lay` at BOTTOM, slug `leave` at LEFT). Fix in 5 places: `my_sea.html:287,292` JS POSITION_LABELS (WS: lay→"Beneath", leave→"Behind"; EV: lay→"Lay", leave→"Leave"), `gameboard/models.py:44-47` Python mirror, `test_game_my_sea.py:618-619` FT label-assertion table, `_sea_overlay.html:28,53` annotated comments (`sea-pos-leave` → "Behind (past) — CC pos 6 / EV pos 4"; `sea-pos-lay` → "Beneath (root) — CC pos 4 / EV pos 3"). Slug-to-CSS mapping, DRAW_ORDER, + DB persistence unchanged → no migrations, no data invalidation. **Crucial for Voronoi mapping correctness** per user spec. (3) **My Sign applet — stage-card layout + stat-block beside.** Applet card markup upgraded to mirror my_sign.html `.sig-stage-card`: corner-tl + face (name + arcana centred) + corner-br (mirror, rotated 180°). Sized to fill applet height via container queries (`--applet-card-w: min(48cqi, 62.5cqh)` — 48cqi caps the card at half the row to leave room for the stat-block). Sibling `.my-sign-applet-stat-block` partial added — emanation/reversal face label + keyword list (from `card.keywords_upright` / `keywords_reversed` keyed off `significator_reversed`), no SPIN/FYI buttons (applet is read-only). Styling cribbed from `.sig-stat-block` in `_card-deck.scss:595-607` — priUser-translucent bg + terUser border + matching `--applet-card-w` sizing. (4) **My Sea sign-gate refactored to Brief banner.** Was an inline `.my-sea-sign-gate` div w. its own SCSS — broke from the project's `Brief.showBanner` portal pattern. Refactored to a shared `_my_sea_sign_gate_brief.html` partial that fires `Brief.showBanner` w. title="Sign required" + line_text="Look!—pick your sign before drawing the Sea." + post_url=`/billboard/my-sign/`. Brief portals to the page-level h2 anchor via `note.js`'s `_alignToH2` (gaussian-glass `.note-banner` shell, FYI button → my-sign picker, NVM dismisses). Modifier class `.my-sea-sign-gate-brief` added post-render for FT selector disambiguation. note.js load hoisted to gameboard.html `{% block scripts %}` + the top of `my_sea.html {% block content %}` (single load per page — note.js declares `const Brief = ...` at global scope, second load = SyntaxError). All `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` SCSS deleted. FTs (`test_no_sig_renders_lookline_gate_on_standalone_page` + 5 siblings) + ITs (`test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig` etc.) updated to assert `.note-banner.my-sea-sign-gate-brief` + the JS-rendered FYI/NVM buttons inside the Brief shell. (5) **Levity card text invisibility fix.** My-sea applet levity slots (--secUser bg) rendered their corner-rank + suit-icon invisible because `.fan-card-corner` carries a global `color: rgba(var(--secUser), 0.75)` rule at `_card-deck.scss:312-319` (specificity 0,1,0) that out-specifics the slot's inherited `color: --priUser`. Same trap as the `.fan-card-name { color: --quiUser }` global. Fix at `_gameboard.scss` inside the levity rule: explicit `.fan-card-corner { color: rgba(var(--priUser), 1) }` + `.fan-card-name { color: rgba(var(--priUser), 1) }` + `.fan-card-arcana { color: rgba(var(--priUser), 0.7) }` overrides at (1,3,1) specificity — beats the globals without `!important`. **Trap captured in memory** — pattern repeats across game-kit, my-sign, my-sea so worth pinning. (6) **--duoUser olive on all five personal-data surfaces.** Per user spec, the four "personal" applets (My Sign on billboard, My Sea on gameboard, My Sky on dashboard) + the standalone Dashsky page + the standalone My Sign page got `background-color: rgba(var(--duoUser), 1)` so they read as a unified olive-bg group across navigation surfaces. For Dashsky specifically, the form column also got the override (`.sky-page .sky-form-col { background: --duoUser }`) — the base `.sky-form-col { background: --priUser }` (`_sky.scss:137`, shared w. the in-room CAST SKY modal) was leaving the dashsky form column purple inside the otherwise-olive page. Scoped to `.sky-page` so the in-room modal's purple form-col stays intact (sits over --secUser room bg, needs that contrast). One detour caught: tried `body.page-sky { background-color: --duoUser }` to fill the gap below .sky-page's content-sized aperture but it bled to navbar + footer (which sit outside .container) — reverted. **TDD coverage**: 3 new ITs in `apps/gameboard/tests/integrated/test_views.py` — `test_my_sea_applet_renders_drawn_cards_in_draw_order` (SAO 1-of-3 fills `lay` slot, cover/crown render as empty placeholders), `test_my_sea_applet_labels_match_locked_spread` (SAO labels exactly Situation/Action/Outcome), `test_my_sea_applet_waite_smith_labels_post_fix` (regression pin for the WS Cover/Cross/Crown/**Beneath**/Before/**Behind** sequence post-swap-fix). Existing my-sea applet ITs updated to match the new selector vocabulary (`.my-sea-slot--filled` instead of `.my-sea-card`, Brief script substring instead of `.my-sea-sign-gate--applet`). 6 my-sea FTs updated to the Brief-banner contract. 1214/1214 IT/UT green. **.gitignore**: temporary entry for `src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/` until images get renamed — flagged for removal once the rename lands. (Per user's wget download of the Minchiate faces into the gameboard cards/ tree this session.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:19:34 -04:00
HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for, latest_draw_slots,
My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + `_select_my_sea_token` / `debit_my_sea_token` helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
_select_my_sea_token, debit_my_sea_token,
)
def _annotate_deck_in_use(decks, user):
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
active = {
ts.deck_variant_id: ts.room.name
for ts in TableSeat.objects.filter(
gamer=user, deck_variant__isnull=False,
).select_related("room")
}
for deck in decks:
deck.in_use_room_name = active.get(deck.pk)
return decks
from apps.epic.models import DeckVariant, Room, TableSeat
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
from apps.epic.utils import annotate_latest_event, rooms_for_user
from apps.lyric.models import Token
GAMEBOARD_APPLET_ORDER = [
"new-game",
"my-games",
"game-kit",
]
@login_required(login_url="/")
def gameboard(request):
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
band = request.user.tokens.filter(token_type=Token.BAND).first()
coin = request.user.tokens.filter(token_type=Token.COIN).first()
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
free_tokens = list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
return render(
request, "apps/gameboard/gameboard.html", {
"pass_token": pass_token,
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
"band": band,
"coin": coin,
"carte": carte,
"equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens,
"free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard",
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
"my_games": annotate_latest_event(rooms_for_user(request.user)),
feat: My Sea applet dynamic population + lay/leave POSITION_LABELS swap fix + My Sign applet stat-block + Brief-fied sign-gate + --duoUser olive on all four personal-data surfaces. Six visual+structural items batched across the dashboard/billboard/gameboard. (1) **My Sea applet dynamic population.** Applet at `_applet-my-sea.html` was referencing an undefined `latest_draw_cards` template var — fell through to "No draws yet" even when the user had an active draw. New helpers in `apps/gameboard/models.py`: `DRAW_ORDER` + `POSITION_LABELS` constants (Python mirrors of the JS dicts in `my_sea.html:274-293`) + `latest_draw_slots(user)` builder that pairs each spread position w. its drawn card + display label + polarity. Wired through `gameboard()` + `toggle_game_applets()` views as `my_sea_slots`. Applet now renders all spread slots in DRAW_ORDER: filled = `.my-sea-slot--filled.my-sea-slot--{gravity,levity}` w. corner-tl + face (name + arcana) + corner-br (mirror) markup (same shape language as my_sign.html `.sig-stage-card`), empty = `.my-sea-slot--empty` w. `0.15rem dashed rgba(var(--terUser), 1)` border (matches the picker's `.sea-card-slot` style exactly so the applet reads as a true scaled-down twin). Container queries (`container-type: size` on `.my-sea-scroll`) lift `--slot-w` to fill the applet's vertical aperture (`min(100cqi, calc((100cqh - 1rem) * 5 / 8))` carves the label row). Position labels pulled tight against the slot's bottom border (`margin-top: -0.15rem` crosses the border line) + vertically stretched (`transform: scaleY(1.4)` mirroring `.sea-pos-label` in `_card-deck.scss:1671-1684`) — empty-slot labels keep the same `--secUser` ink as filled-slot labels for title cohesion across the row. Horizontal-scroll on multi-card spreads via mousewheel — `bindMySeaWheel()` in `gameboard.js` translates vertical wheel events to `scrollLeft += deltaY` (lifted verbatim from `bindPaletteWheel` in `dashboard.js:7-14`). (2) **lay/leave POSITION_LABELS swap fix.** User caught in the Escape Velocity picker that LEFT slot read "Lay" + BOTTOM slot read "Leave" — opposite of traditional Celtic Cross semantics (LEFT = Behind/past, BOTTOM = Beneath/root). Root cause: POSITION_LABELS for both Waite-Smith + Escape Velocity had `lay`/`leave` slug→label assignments inverted vs the CSS grid's spatial mapping (`_card-deck.scss:1276-1279` puts slug `lay` at BOTTOM, slug `leave` at LEFT). Fix in 5 places: `my_sea.html:287,292` JS POSITION_LABELS (WS: lay→"Beneath", leave→"Behind"; EV: lay→"Lay", leave→"Leave"), `gameboard/models.py:44-47` Python mirror, `test_game_my_sea.py:618-619` FT label-assertion table, `_sea_overlay.html:28,53` annotated comments (`sea-pos-leave` → "Behind (past) — CC pos 6 / EV pos 4"; `sea-pos-lay` → "Beneath (root) — CC pos 4 / EV pos 3"). Slug-to-CSS mapping, DRAW_ORDER, + DB persistence unchanged → no migrations, no data invalidation. **Crucial for Voronoi mapping correctness** per user spec. (3) **My Sign applet — stage-card layout + stat-block beside.** Applet card markup upgraded to mirror my_sign.html `.sig-stage-card`: corner-tl + face (name + arcana centred) + corner-br (mirror, rotated 180°). Sized to fill applet height via container queries (`--applet-card-w: min(48cqi, 62.5cqh)` — 48cqi caps the card at half the row to leave room for the stat-block). Sibling `.my-sign-applet-stat-block` partial added — emanation/reversal face label + keyword list (from `card.keywords_upright` / `keywords_reversed` keyed off `significator_reversed`), no SPIN/FYI buttons (applet is read-only). Styling cribbed from `.sig-stat-block` in `_card-deck.scss:595-607` — priUser-translucent bg + terUser border + matching `--applet-card-w` sizing. (4) **My Sea sign-gate refactored to Brief banner.** Was an inline `.my-sea-sign-gate` div w. its own SCSS — broke from the project's `Brief.showBanner` portal pattern. Refactored to a shared `_my_sea_sign_gate_brief.html` partial that fires `Brief.showBanner` w. title="Sign required" + line_text="Look!—pick your sign before drawing the Sea." + post_url=`/billboard/my-sign/`. Brief portals to the page-level h2 anchor via `note.js`'s `_alignToH2` (gaussian-glass `.note-banner` shell, FYI button → my-sign picker, NVM dismisses). Modifier class `.my-sea-sign-gate-brief` added post-render for FT selector disambiguation. note.js load hoisted to gameboard.html `{% block scripts %}` + the top of `my_sea.html {% block content %}` (single load per page — note.js declares `const Brief = ...` at global scope, second load = SyntaxError). All `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` SCSS deleted. FTs (`test_no_sig_renders_lookline_gate_on_standalone_page` + 5 siblings) + ITs (`test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig` etc.) updated to assert `.note-banner.my-sea-sign-gate-brief` + the JS-rendered FYI/NVM buttons inside the Brief shell. (5) **Levity card text invisibility fix.** My-sea applet levity slots (--secUser bg) rendered their corner-rank + suit-icon invisible because `.fan-card-corner` carries a global `color: rgba(var(--secUser), 0.75)` rule at `_card-deck.scss:312-319` (specificity 0,1,0) that out-specifics the slot's inherited `color: --priUser`. Same trap as the `.fan-card-name { color: --quiUser }` global. Fix at `_gameboard.scss` inside the levity rule: explicit `.fan-card-corner { color: rgba(var(--priUser), 1) }` + `.fan-card-name { color: rgba(var(--priUser), 1) }` + `.fan-card-arcana { color: rgba(var(--priUser), 0.7) }` overrides at (1,3,1) specificity — beats the globals without `!important`. **Trap captured in memory** — pattern repeats across game-kit, my-sign, my-sea so worth pinning. (6) **--duoUser olive on all five personal-data surfaces.** Per user spec, the four "personal" applets (My Sign on billboard, My Sea on gameboard, My Sky on dashboard) + the standalone Dashsky page + the standalone My Sign page got `background-color: rgba(var(--duoUser), 1)` so they read as a unified olive-bg group across navigation surfaces. For Dashsky specifically, the form column also got the override (`.sky-page .sky-form-col { background: --duoUser }`) — the base `.sky-form-col { background: --priUser }` (`_sky.scss:137`, shared w. the in-room CAST SKY modal) was leaving the dashsky form column purple inside the otherwise-olive page. Scoped to `.sky-page` so the in-room modal's purple form-col stays intact (sits over --secUser room bg, needs that contrast). One detour caught: tried `body.page-sky { background-color: --duoUser }` to fill the gap below .sky-page's content-sized aperture but it bled to navbar + footer (which sit outside .container) — reverted. **TDD coverage**: 3 new ITs in `apps/gameboard/tests/integrated/test_views.py` — `test_my_sea_applet_renders_drawn_cards_in_draw_order` (SAO 1-of-3 fills `lay` slot, cover/crown render as empty placeholders), `test_my_sea_applet_labels_match_locked_spread` (SAO labels exactly Situation/Action/Outcome), `test_my_sea_applet_waite_smith_labels_post_fix` (regression pin for the WS Cover/Cross/Crown/**Beneath**/Before/**Behind** sequence post-swap-fix). Existing my-sea applet ITs updated to match the new selector vocabulary (`.my-sea-slot--filled` instead of `.my-sea-card`, Brief script substring instead of `.my-sea-sign-gate--applet`). 6 my-sea FTs updated to the Brief-banner contract. 1214/1214 IT/UT green. **.gitignore**: temporary entry for `src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/` until images get renamed — flagged for removal once the rename lands. (Per user's wget download of the Minchiate faces into the gameboard cards/ tree this session.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:19:34 -04:00
"my_sea_slots": latest_draw_slots(request.user),
}
)
@login_required(login_url="/")
def toggle_game_applets(request):
checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "gameboard", checked)
if request.headers.get("HX-Request"):
free_tokens = list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
return render(request, "apps/gameboard/_partials/_applets.html", {
"applets": applet_context(request.user, "gameboard"),
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens,
"free_count": len(free_tokens),
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
"my_games": annotate_latest_event(rooms_for_user(request.user)),
feat: My Sea applet dynamic population + lay/leave POSITION_LABELS swap fix + My Sign applet stat-block + Brief-fied sign-gate + --duoUser olive on all four personal-data surfaces. Six visual+structural items batched across the dashboard/billboard/gameboard. (1) **My Sea applet dynamic population.** Applet at `_applet-my-sea.html` was referencing an undefined `latest_draw_cards` template var — fell through to "No draws yet" even when the user had an active draw. New helpers in `apps/gameboard/models.py`: `DRAW_ORDER` + `POSITION_LABELS` constants (Python mirrors of the JS dicts in `my_sea.html:274-293`) + `latest_draw_slots(user)` builder that pairs each spread position w. its drawn card + display label + polarity. Wired through `gameboard()` + `toggle_game_applets()` views as `my_sea_slots`. Applet now renders all spread slots in DRAW_ORDER: filled = `.my-sea-slot--filled.my-sea-slot--{gravity,levity}` w. corner-tl + face (name + arcana) + corner-br (mirror) markup (same shape language as my_sign.html `.sig-stage-card`), empty = `.my-sea-slot--empty` w. `0.15rem dashed rgba(var(--terUser), 1)` border (matches the picker's `.sea-card-slot` style exactly so the applet reads as a true scaled-down twin). Container queries (`container-type: size` on `.my-sea-scroll`) lift `--slot-w` to fill the applet's vertical aperture (`min(100cqi, calc((100cqh - 1rem) * 5 / 8))` carves the label row). Position labels pulled tight against the slot's bottom border (`margin-top: -0.15rem` crosses the border line) + vertically stretched (`transform: scaleY(1.4)` mirroring `.sea-pos-label` in `_card-deck.scss:1671-1684`) — empty-slot labels keep the same `--secUser` ink as filled-slot labels for title cohesion across the row. Horizontal-scroll on multi-card spreads via mousewheel — `bindMySeaWheel()` in `gameboard.js` translates vertical wheel events to `scrollLeft += deltaY` (lifted verbatim from `bindPaletteWheel` in `dashboard.js:7-14`). (2) **lay/leave POSITION_LABELS swap fix.** User caught in the Escape Velocity picker that LEFT slot read "Lay" + BOTTOM slot read "Leave" — opposite of traditional Celtic Cross semantics (LEFT = Behind/past, BOTTOM = Beneath/root). Root cause: POSITION_LABELS for both Waite-Smith + Escape Velocity had `lay`/`leave` slug→label assignments inverted vs the CSS grid's spatial mapping (`_card-deck.scss:1276-1279` puts slug `lay` at BOTTOM, slug `leave` at LEFT). Fix in 5 places: `my_sea.html:287,292` JS POSITION_LABELS (WS: lay→"Beneath", leave→"Behind"; EV: lay→"Lay", leave→"Leave"), `gameboard/models.py:44-47` Python mirror, `test_game_my_sea.py:618-619` FT label-assertion table, `_sea_overlay.html:28,53` annotated comments (`sea-pos-leave` → "Behind (past) — CC pos 6 / EV pos 4"; `sea-pos-lay` → "Beneath (root) — CC pos 4 / EV pos 3"). Slug-to-CSS mapping, DRAW_ORDER, + DB persistence unchanged → no migrations, no data invalidation. **Crucial for Voronoi mapping correctness** per user spec. (3) **My Sign applet — stage-card layout + stat-block beside.** Applet card markup upgraded to mirror my_sign.html `.sig-stage-card`: corner-tl + face (name + arcana centred) + corner-br (mirror, rotated 180°). Sized to fill applet height via container queries (`--applet-card-w: min(48cqi, 62.5cqh)` — 48cqi caps the card at half the row to leave room for the stat-block). Sibling `.my-sign-applet-stat-block` partial added — emanation/reversal face label + keyword list (from `card.keywords_upright` / `keywords_reversed` keyed off `significator_reversed`), no SPIN/FYI buttons (applet is read-only). Styling cribbed from `.sig-stat-block` in `_card-deck.scss:595-607` — priUser-translucent bg + terUser border + matching `--applet-card-w` sizing. (4) **My Sea sign-gate refactored to Brief banner.** Was an inline `.my-sea-sign-gate` div w. its own SCSS — broke from the project's `Brief.showBanner` portal pattern. Refactored to a shared `_my_sea_sign_gate_brief.html` partial that fires `Brief.showBanner` w. title="Sign required" + line_text="Look!—pick your sign before drawing the Sea." + post_url=`/billboard/my-sign/`. Brief portals to the page-level h2 anchor via `note.js`'s `_alignToH2` (gaussian-glass `.note-banner` shell, FYI button → my-sign picker, NVM dismisses). Modifier class `.my-sea-sign-gate-brief` added post-render for FT selector disambiguation. note.js load hoisted to gameboard.html `{% block scripts %}` + the top of `my_sea.html {% block content %}` (single load per page — note.js declares `const Brief = ...` at global scope, second load = SyntaxError). All `.my-sea-sign-gate{,--applet,__line,__actions,__back,__fyi}` SCSS deleted. FTs (`test_no_sig_renders_lookline_gate_on_standalone_page` + 5 siblings) + ITs (`test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig` etc.) updated to assert `.note-banner.my-sea-sign-gate-brief` + the JS-rendered FYI/NVM buttons inside the Brief shell. (5) **Levity card text invisibility fix.** My-sea applet levity slots (--secUser bg) rendered their corner-rank + suit-icon invisible because `.fan-card-corner` carries a global `color: rgba(var(--secUser), 0.75)` rule at `_card-deck.scss:312-319` (specificity 0,1,0) that out-specifics the slot's inherited `color: --priUser`. Same trap as the `.fan-card-name { color: --quiUser }` global. Fix at `_gameboard.scss` inside the levity rule: explicit `.fan-card-corner { color: rgba(var(--priUser), 1) }` + `.fan-card-name { color: rgba(var(--priUser), 1) }` + `.fan-card-arcana { color: rgba(var(--priUser), 0.7) }` overrides at (1,3,1) specificity — beats the globals without `!important`. **Trap captured in memory** — pattern repeats across game-kit, my-sign, my-sea so worth pinning. (6) **--duoUser olive on all five personal-data surfaces.** Per user spec, the four "personal" applets (My Sign on billboard, My Sea on gameboard, My Sky on dashboard) + the standalone Dashsky page + the standalone My Sign page got `background-color: rgba(var(--duoUser), 1)` so they read as a unified olive-bg group across navigation surfaces. For Dashsky specifically, the form column also got the override (`.sky-page .sky-form-col { background: --duoUser }`) — the base `.sky-form-col { background: --priUser }` (`_sky.scss:137`, shared w. the in-room CAST SKY modal) was leaving the dashsky form column purple inside the otherwise-olive page. Scoped to `.sky-page` so the in-room modal's purple form-col stays intact (sits over --secUser room bg, needs that contrast). One detour caught: tried `body.page-sky { background-color: --duoUser }` to fill the gap below .sky-page's content-sized aperture but it bled to navbar + footer (which sit outside .container) — reverted. **TDD coverage**: 3 new ITs in `apps/gameboard/tests/integrated/test_views.py` — `test_my_sea_applet_renders_drawn_cards_in_draw_order` (SAO 1-of-3 fills `lay` slot, cover/crown render as empty placeholders), `test_my_sea_applet_labels_match_locked_spread` (SAO labels exactly Situation/Action/Outcome), `test_my_sea_applet_waite_smith_labels_post_fix` (regression pin for the WS Cover/Cross/Crown/**Beneath**/Before/**Behind** sequence post-swap-fix). Existing my-sea applet ITs updated to match the new selector vocabulary (`.my-sea-slot--filled` instead of `.my-sea-card`, Brief script substring instead of `.my-sea-sign-gate--applet`). 6 my-sea FTs updated to the Brief-banner contract. 1214/1214 IT/UT green. **.gitignore**: temporary entry for `src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/` until images get renamed — flagged for removal once the rename lands. (Per user's wget download of the Minchiate faces into the gameboard cards/ tree this session.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:19:34 -04:00
"my_sea_slots": latest_draw_slots(request.user),
})
return redirect("gameboard")
@login_required(login_url="/")
def equip_trinket(request, token_id):
token = get_object_or_404(Token, pk=token_id, user=request.user)
if request.method == "POST":
request.user.equipped_trinket = token
request.user.save(update_fields=["equipped_trinket"])
return HttpResponse(status=204)
return render(
request,
"apps/gameboard/_partials/_equip_trinket_btn.html",
{"token": token},
)
@login_required(login_url="/")
def equip_deck(request, deck_id):
deck = get_object_or_404(DeckVariant, pk=deck_id)
if request.method == "POST":
request.user.equipped_deck = deck
request.user.save(update_fields=["equipped_deck"])
return HttpResponse(status=204)
return HttpResponse(status=405)
@login_required(login_url="/")
def unequip_trinket(request, token_id):
token = get_object_or_404(Token, pk=token_id, user=request.user)
if request.method == "POST":
if request.user.equipped_trinket_id == token.pk:
request.user.equipped_trinket = None
request.user.save(update_fields=["equipped_trinket"])
return HttpResponse(status=204)
return HttpResponse(status=405)
@login_required(login_url="/")
def unequip_deck(request, deck_id):
get_object_or_404(DeckVariant, pk=deck_id)
if request.method == "POST":
if request.user.equipped_deck_id == deck_id:
request.user.equipped_deck = None
request.user.save(update_fields=["equipped_deck"])
return HttpResponse(status=204)
return HttpResponse(status=405)
def _game_kit_context(user):
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
from apps.lyric.models import PRONOUN_CHOICES
coin = user.tokens.filter(token_type=Token.COIN).first()
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
band = user.tokens.filter(token_type=Token.BAND).first()
carte = user.tokens.filter(token_type=Token.CARTE).first()
free_tokens = list(user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
pronoun_options = [
{"key": k, "label": label, "active": (k == user.pronouns)}
for (k, label) in PRONOUN_CHOICES
]
return {
"coin": coin,
"pass_token": pass_token,
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
"band": band,
"carte": carte,
"free_tokens": free_tokens,
"tithe_tokens": tithe_tokens,
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock. **(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status. **(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment. **(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec. **(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here. **(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG). **(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific). **(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX). **(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it. **(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow. Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
# `free_count` / `tithe_count` mirror the gameboard Game Kit applet's
# context — drive the `(×N)` chip in the .tt-title plus the rendered
# `.shop-badge` on the icon. Both capped to "99+" at the template
# level so a runaway count doesn't blow the badge layout.
"free_count": len(free_tokens),
"tithe_count": len(tithe_tokens),
# `deck_variants` is the annotated list (carries .in_use_room_name
# per `_annotate_deck_in_use`); the gk-decks section uses the same
# SVG card-stack icon + tt-tooltip pattern as the gameboard's Game
# Kit applet so the deck row reads identically on both surfaces.
"deck_variants": _annotate_deck_in_use(list(user.unlocked_decks.all()), user),
"equipped_deck_id": user.equipped_deck_id,
# `equipped_trinket_id` powers the gk-trinkets section's DON/DOFF
# buttons + mini-portal Equipped status (parity w. gameboard applet).
"equipped_trinket_id": user.equipped_trinket_id,
"applets": applet_context(user, "game-kit"),
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
"pronoun_options": pronoun_options,
"current_pronouns": user.pronouns,
}
@login_required(login_url="/")
def game_kit(request):
return render(request, "apps/gameboard/game_kit.html", {
**_game_kit_context(request.user),
"page_class": "page-gameboard",
})
@login_required(login_url="/")
def toggle_game_kit_sections(request):
checked = request.POST.getlist("applets")
apply_applet_toggle(request.user, "game-kit", checked)
if request.headers.get("HX-Request"):
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
_game_kit_context(request.user))
return redirect("game_kit")
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
@login_required(login_url="/")
def my_sea(request):
"""Shell view for the My Sea standalone page.
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
Sprint 5 iter 4c branching `MySeaDraw` now plays double-duty as
hand storage AND 24h quota tracker. The row is created on first
card draw + survives DEL (which only wipes the hand). View states:
1. No sig Look!-formatted sign-gate (Sprint 4b).
2. Active draw, hand non-empty (mid-draw or complete) picker phase
w. saved hand state. The DEL btn is server-rendered `.btn-
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
completion. The draw's sig snapshot is rendered (NOT user.
significator) so a cleared sig elsewhere doesn't invalidate the
saved draw.
3. Active draw, hand empty (post-DEL) landing w. GATE VIEW (the
free-draw quota is spent; user must use tokens via the upcoming
Sprint 6 gatekeeper). Brief banner still surfaces the next-
free-draw timestamp.
4. No active draw + sig set landing w. FREE DRAW (the daily quota
is available).
4a. + no equipped deck also show backup-deck Brief banner.
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
"""
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
active_draw = active_draw_for(request.user)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
user_has_sig = sig_card is not None
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
no_equipped_deck = request.user.equipped_deck_id is None
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
if active_draw is not None:
default_spread = active_draw.spread
saved_hand = active_draw.hand
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
hand_complete = active_draw.is_hand_complete
hand_empty = active_draw.is_hand_empty
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
else:
default_spread = "situation-action-outcome"
saved_hand = []
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
hand_complete = False
hand_empty = True
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>
2026-05-23 15:06:35 -04:00
# 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)
)
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>
2026-05-23 15:06:35 -04:00
# 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.
My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable). ## Navbar GATE VIEW swap `templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav). **Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches. `_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`). ## Landing center-btn 3-way state machine `my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars. `my_sea.html` landing branches: - `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip. - `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper). - else → **FREE DRAW** (existing iter-1 btn). Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time. ## Seat-1 server-render `my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw. In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence. ## FT delta **Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats. **Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about. ## Tests - 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s. - All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged. - 153/153 gameboard ITs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00
deposit_reserved = (
active_draw is not None and active_draw.deposit_token_id is not None
)
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>
2026-05-23 15:06:35 -04:00
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
My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable). ## Navbar GATE VIEW swap `templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav). **Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches. `_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`). ## Landing center-btn 3-way state machine `my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars. `my_sea.html` landing branches: - `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip. - `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper). - else → **FREE DRAW** (existing iter-1 btn). Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time. ## Seat-1 server-render `my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw. In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence. ## FT delta **Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats. **Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about. ## Tests - 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s. - All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged. - 153/153 gameboard ITs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00
hand_non_empty = active_draw is not None and bool(active_draw.hand)
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>
2026-05-23 15:06:35 -04:00
# 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)
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
# Per-position lookup for the template — keyed by the position slug
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
# either its saved card OR an `--empty` slot via a single `{% with
# entry=saved_by_position.lay %}` block. The card fields (corner_rank,
# suit_icon) come pre-resolved so the template doesn't need to do a
# DB lookup per slot.
saved_by_position = {}
if saved_hand:
from apps.epic.models import TarotCard
ids = [e["card_id"] for e in saved_hand]
cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)}
for entry in saved_hand:
c = cards_by_id.get(entry["card_id"])
saved_by_position[entry["position"]] = {
"card_id": entry["card_id"],
"reversed": entry.get("reversed", False),
"polarity": entry.get("polarity", "gravity"),
"corner_rank": c.corner_rank if c else "",
"suit_icon": c.suit_icon if c else "",
A.7-polish my_sea slot image-rendering (server-saved + mid-draw JS) + non-polarized single-deck-stack collapse — TDD. End-of-session wrap-up 2026-05-25 PM. Three changes covering my_sea.html surfaces that weren't in A.5/A.7's central-sig-only scope: (1) saved-hand slot rendering (`_my_sea_slot.html` server-rendered partial fired when a draw is resumed via refresh); (2) mid-draw slot fill (sea.js's `_fillSlot` writes slot.innerHTML on each card-deposit click; previously rendered corner-rank + suit-icon only, NOW renders <img> when card.image_url is non-empty); (3) deck-stack collapse for non-polarized decks (Minchiate today) — the bottom-right of my_sea.html showed two side-by-side GRAVITY + LEVITY stacks regardless of equipped-deck polarization; for non-polarized decks polarity has no meaning so the dual layout misleads. **Critical lock**: collapse is my_sea-ONLY. room.html keeps the dual stacks since multiple gamers contribute (each might bring a different polarization). Server-side template branches `{% if request.user.equipped_deck.is_polarized %}` to pick dual vs. single rendering; the `--single` stack carries the actual deck back-image via `<img class="sea-stack-face-img">` (object-fit: cover) when has_card_images=True. Sub-changes: `card_dict()` in `apps/epic/utils.py` now includes `image_url` + `arcana_key` fields so the picker grid's JSON payload carries the data the JS fill-handler needs (single source of truth shared w. the gameroom sea_deck endpoint — `apps/gameboard/views.py`'s `saved_by_position` dict gets the parallel additions for server-rendered saved hand). SCSS: extended the shared image-mode rule's comma-list selector in `_card-deck.scss` to include `.sea-card-slot.sea-card-slot--image` so the contour stroke + depth shadow apply to both saved + mid-draw slots from a single rule definition. Also added `.sea-deck-stack--single .sea-stack-face` block w. neutral --priUser/--terUser palette (vs. gravity's --quiUser/--quaUser + levity's --terUser/--ninUser) + the corresponding hover/active glow rule positioned AFTER the `$_sea-shadow` SCSS variable definition at line 1808 (initial draft hit a compile error: `Undefined variable: "$_sea-shadow"` because the hover rule was placed before the variable was defined; SCSS variables are scope/order-dependent). JS: `_fillSlot` in `sea.js` branches on `card.image_url` — when non-empty, write `<img class="sig-stage-card-img">` + add `.sea-card-slot--image` marker + `data-arcana-key` attr; otherwise legacy corner-rank + suit-icon. innerHTML alt-attribute properly escapes `"` to `&quot;` so card names w. quotes (none today, but defensive) don't break HTML. Existing JS that activates a clicked stack (`_activeStack` flow + `_showOk` / `_hideOk`) works unchanged w. the single-stack variant since the selector `.sea-deck-stack` matches all variants regardless of polarity suffix; the `isLevity = stack.classList.contains('sea-deck-stack--levity')` check at the deposit moment returns false for `--single` → defaults to gravity polarity assignment, which is fine for non-polarized decks (polarity field has no card-content effect). Memory updated: `project_image_based_deck_face_rendering.md` now lists A.0-A.7 done + this polish + room.html (A.8) as the sole remaining surface for tomorrow. The 6-surface scope sheet shows A.8 as the last red box; everything else green. Tests: 1306/1306 IT+UT total green (73s). No new ITs in this commit — the saved-slot render touch was an extension of existing `saved_by_position` view context shape (covered by existing slot-render tests' implicit invariance); the JS change is hard to test via Django ITs (would need Jasmine spec or FT, deferred); the deck-stack collapse is a template branch (visual; user verified live in browser this session). Tomorrow: A.8 room.html image-rendering (multi-user surface via Channels WebSocket payload + same template branch pattern; keep dual gravity/levity stacks per user spec) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:16:53 -04:00
# Sprint A.7-polish: extra fields for image-mode slot render
# in `_my_sea_slot.html`. Empty strings when the card's deck
# has no images (legacy text-only); template branches on
# `has_card_images` to pick render mode.
"has_card_images": (c.deck_variant.has_card_images
if c and c.deck_variant else False),
"image_url": c.image_url if c else "",
"arcana": c.arcana if c else "",
"name": c.name if c else "",
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
}
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
return render(request, "apps/gameboard/my_sea.html", {
My Sea sign-gate — Sprint 4b of My Sea roadmap — TDD /gameboard/my-sea/ standalone page + /gameboard/ My Sea applet gated behind User.significator. When no sig is saved, render a Look!-formatted Brief-style line — "Look!—pick your sign before drawing the Sea." — w. BACK (.btn-cancel → /gameboard/) + FYI (.btn-info → /billboard/my-sign/) action buttons in `--terUser` ink ; gate is inline content (not portaled like .note-banner) — it IS the page content until a sig is picked, not a transient nudge ; applet partial mirrors the gate via `{% if not request.user.significator_id %}` w. a `.my-sea-sign-gate--applet` denser variant (just FYI, no BACK — user's already on the gameboard); .my-sea-sign-gate__line shrinks from 1.1rem → 0.85rem + padding from 1.5rem → 0.5rem ; my_sea view passes `user_has_sig = request.user.significator_id is not None` so the standalone template branches at server side (avoids a request.user template-context-processor dependency in the standalone page) ; .woodpecker/main.yaml routes test_game_my_sea.py to the test-FTs-non-room stage by default (FT doesn't yet touch the table hex — Sprint 5+ will bring the hex into my_sea via the same DRY .room-shell stack as my_sign, at which point the file gets moved to test-FTs-room) ; TDD trail — 6 FTs in test_game_my_sea.py covering standalone gate copy + FYI/BACK href targets (T1-T3), with-sig skips gate + renders draw shell (T4), applet mirrors gate w. FYI (T5), applet w. sig falls back to .my-sea-empty (T6); all written red against the un-implemented gate before view/template/SCSS landed ; KNOWN: visual verification on existing admin user (@disco) blocked by lack of a clear-sign affordance (he has a sig saved from Sprint 4a testing); adjacent feature spec'd in [[sprint_my_sea_sign_gate_may19]] memory — likely lands as a CLEAR btn in the picker's saved-sig stage state, deferred to next session Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 01:38:55 -04:00
"user_has_sig": user_has_sig,
My Sea DRAW SEA landing — Sprint 5 iter 1 of My Sea roadmap — TDD DRAW SEA landing UX on /gameboard/my-sea/ for users past the [[sprint-my-sea-sign-gate-may19]] gate. DRY table hex (reused from the room shell + my-sign Sprint 4a iter 3) w. 6 chair seats labeled 1C-6C (placeholder for friend-invite per the My Sea roadmap "Six chairs retained even in solo" anchor) + central DRAW SEA `.btn-primary` mirroring SCAN SIGN on /billboard/my-sign/. Click swaps `.my-sea-page[data-phase]` from `landing` to `picker`; the picker UX itself (three-card cross w. cover/leave/loom + form col / spread dropdown / decks / LOCK HAND / DEL) lands in iters 2 + 3. The 'C' suffix on the chair labels = "Chair" (user-locked); no role semantics (this is a solo draw, not a 6-player role assignment). `.table-seat` CSS class + `data-slot` attribute preserved so the room's existing `[data-slot="N"]` positioning rules (`_room.scss` L583-588) carry over for free — no SCSS fork; just a new `.seat-label` span inside each seat. The 'Default deck warning' Brief banner from /billboard/my-sign/ fires verbatim when `user.equipped_deck` is None (the user is headed for a draw against the Earthman [Shabby Cardstock] backup unless they equip one first). Tagged `.my-sea-intro-banner` so FTs disambiguate from other Briefs. Same FYI (→ /gameboard/) / NVM (dismiss) action grammar. Bundled: BACK→NVM label swap (user-edited mid-sprint) in the existing sign-gate. CSS class `.my-sea-sign-gate__back` retained — the swap was label-only — so existing FTs targeting the class still pass; docstrings + comments updated for accuracy. Files: - `apps/gameboard/views.py` — `my_sea` view adds 2 context keys: `no_equipped_deck` (bool) + `show_backup_intro_banner` (= user_has_sig AND no_equipped_deck). The sig-gate path still wins precedence. - `templates/apps/gameboard/my_sea.html` — `.my-sea-page[data-phase="landing"]`; new `.my-sea-landing` block w. room-shell hex + `#id_draw_sea_btn` + 6 `.table-seat[data-slot="N"]` w. `<span class="seat-label">NC</span>`; new `.my-sea-picker` placeholder (`display:none` til DRAW SEA click); inline `<script>` for the click→data-phase swap + scaleTable re-fire on next tick (mirrors my-sign's iter-3 RAF dispatch); copies the my-sign Brief banner script block verbatim w. `.my-sea-intro-banner` post-render tag. - `static_src/scss/_gameboard.scss` — new `.my-sea-page` + `.my-sea-landing` + `.my-sea-picker` rule blocks mirroring `.my-sign-page` / `.my-sign-landing` from `_card-deck.scss`. `.seat-label` styled in `--terUser` to match the chair iconography. - `apps/gameboard/tests/integrated/test_views.py` — `+7 ITs` in new `MySeaDrawSeaLandingViewTest` pinning the context keys + presence/absence of `#id_draw_sea_btn` + 6 `data-slot=N` w. `NC` labels + Sprint 4b gate's precedence over the new landing. - `functional_tests/test_game_my_sea.py` — `+5 FTs` in new `MySeaDrawSeaLandingTest` for the visible UX (hex + DRAW SEA, 6 seats labeled 1C-6C, click→picker phase swap, Brief banner on no-deck, no banner when deck equipped). Uses new `_assign_sig` helper from [[sprint-sig-page-helper-may19c]] for the user-w-sig precondition. Tests: 25/25 FTs green across test_bill_my_sign + test_game_my_sea in 219s; 1036/1036 IT/UT green in 50s (+7 from baseline). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:15:37 -04:00
"no_equipped_deck": no_equipped_deck,
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
"show_backup_intro_banner": (
user_has_sig and no_equipped_deck and active_draw is None
),
"significator": sig_card,
"significator_reversed": sig_reversed,
"default_spread": default_spread,
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide. Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only. - **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys. - **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in. - **SCSS** (_gameboard.scss): - `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross. - `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells. - `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover. - `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side. **Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default): - FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering. - IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally). **Tests**: - 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements). - 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render. Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s. Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
"reversals_pct": 25,
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
"sea_deck_data": (
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
if user_has_sig else {"levity": [], "gravity": []}
),
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
# Iter 4b / 4c
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
"active_draw": active_draw,
"saved_hand": saved_hand,
"saved_by_position": saved_by_position,
"next_free_draw_at": next_free_draw_at,
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
"hand_complete": hand_complete,
"show_picker": show_picker,
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>
2026-05-23 15:06:35 -04:00
"show_paid_draw": show_paid_draw,
"show_gate_view": show_gate_view,
My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable). ## Navbar GATE VIEW swap `templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav). **Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches. `_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`). ## Landing center-btn 3-way state machine `my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars. `my_sea.html` landing branches: - `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip. - `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper). - else → **FREE DRAW** (existing iter-1 btn). Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time. ## Seat-1 server-render `my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw. In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence. ## FT delta **Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats. **Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about. ## Tests - 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s. - All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged. - 153/153 gameboard ITs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00
"deposit_reserved": deposit_reserved,
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>
2026-05-23 15:06:35 -04:00
"paid_through": paid_through,
My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable). ## Navbar GATE VIEW swap `templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav). **Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches. `_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`). ## Landing center-btn 3-way state machine `my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars. `my_sea.html` landing branches: - `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip. - `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper). - else → **FREE DRAW** (existing iter-1 btn). Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time. ## Seat-1 server-render `my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw. In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence. ## FT delta **Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats. **Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about. ## Tests - 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s. - All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged. - 153/153 gameboard ITs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00
"hand_non_empty": hand_non_empty,
My Sea applet shell — Sprint 3 of the My Sea roadmap User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
"page_class": "page-gameboard page-my-sea",
})
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
def _resolve_sig(user, active_draw):
"""When an active draw exists, render its sig snapshot — even if
user.significator has since been cleared (per user spec, preserve the
old sig on the saved draw). Otherwise use user.significator."""
if active_draw is not None:
from apps.epic.models import TarotCard
sig = TarotCard.objects.filter(id=active_draw.significator_id).first()
return sig, active_draw.significator_reversed
return user.significator, user.significator_reversed
@login_required(login_url="/")
@require_POST
def my_sea_lock(request):
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
"""Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
fires on every card placement (manual FLIP or AUTO DRAW completion)
rather than only on a discrete LOCK HAND action.
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
polarity}, ...]}` `hand` is the current FULL state (partial OK
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
Returns:
200 `{ok, next_free_draw_at, hand_complete}` on success
400 malformed payload or no sig
409 spread differs from the user's already-active draw's spread
(the spread is locked at first-card moment; can't switch mid-
draw via a sneaky POST)
"""
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
return JsonResponse({"error": "invalid_json"}, status=400)
spread = payload.get("spread")
hand = payload.get("hand")
if not spread or not isinstance(hand, list) or not hand:
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
if spread not in HAND_SIZE_BY_SPREAD:
return JsonResponse({"error": "unknown_spread"}, status=400)
existing = active_draw_for(request.user)
if existing is not None:
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
# through the gatekeeper but the endpoint stays permissive here).
A.7-polish-3 stat-block title + arcana fields across 3 surfaces + spread-switch unlock after DEL — TDD. End-of-session 2026-05-25 PM. Two changes bundled (both user-requested as round-out work for tonight's final push): 1. Stat-block restructure per [[project-image-based-deck-face-rendering]]'s locked Q3 spec. User noticed during browser verify that image-mode card surfaces (Minchiate-equipped on my_sign main stage + My Sign applet + Sea Stage modal in my_sea) show only the EMANATION label in the stat-block — no title, no arcana type, no keywords (Minchiate keywords are empty per A.1 seed). For text-mode decks (Earthman, RWS) the title + arcana render ON the card; for image-mode the card is just an image so all textual metadata MUST move to the stat-block. Spec was originally written for image-mode only but user explicitly asked for it universally so non-image cards also get the info in the stat-block (visible duplicates w. card content — acceptable tradeoff per user). Implementation: added `<p class="stat-face-title">` + `<p class="stat-face-arcana">` to both upright + reversed `.stat-face` blocks in `my_sign.html` (line ~58) + `_sea_stage.html` (the modal). For `_applet-my-sign.html` (no SPIN btn, no reversed face), rendered server-side from `card.name` + `card.get_arcana_display` + the new `data-arcana-key="{{ card.arcana }}"` attr on the stat-block wrapper. New JS helper `StageCard.populateStatExtras(statBlock, card, opts)` in `stage-card.js` parallels the existing `populateKeywords` — fills `.stat-face-title` (card name minus any "Title, Qualifier" Earthman pattern stripping) + `.stat-face-arcana` (`_arcanaDisplay(card)` reused) + sets `data-arcana-key` on the stat-block parent for SCSS color-keying. Exported alongside populateKeywords; called from `my_sign.html` inline + `sea.js`'s `_populate` (the 2 dynamic stat-block sites). `sig-select.js` (room sig select) intentionally NOT updated — that's A.8 territory + its stat block markup differs. SCSS: extended `stat-block-shared` mixin in `_card-deck.scss` w. `.stat-face-title` (font-weight 700, color --quiUser default; `[data-arcana-key="MAJOR"]` selector flips to --terUser matching the contour-stroke arcana-color convention) + `.stat-face-arcana` (uppercase letter-spaced like `.stat-face-label`). Same rules duplicated in `_billboard.scss` `.my-sign-applet-stat-block` block (different sizing via `--applet-card-w` container query). `:empty` rule hides both title + arcana when JS hasn't populated yet (rest state — prevents zero-height paragraphs inflating the stat block). Also added user-spec'd underline to `.stat-face-label` (text-decoration: underline + 0.15em offset). 2. Spread-switch policy unlock after DEL. User-reported AUTO DRAW failure ("only works with default SOA spread, others give visual click feedback but no cards") + spread-switch failure ("won't persist with a partial draw either, only SAO will"). Investigated: root cause is `views.my_sea_lock` line 372 returning `409 spread_mismatch` whenever the POST's spread != the existing `MySeaDraw` row's spread. The row spread is committed at first-card moment and `active_draw_for` returns rows for 24h regardless of hand state (even empty post-DEL rows still hold the spread lock). Combobox switches visually but every subsequent POST 409s. Refined policy: spread is locked only during an ACTIVE non-empty draw. Once the user DELs (clears hand to []), the spread lock lifts — a POST w. a different spread UPDATES the existing row's spread + populates the new hand. The 24h quota window (created_at + paid_through_at) is preserved so the cooldown clock stays put. Sneaky-POST mitigation is still in effect for mid-non-empty-draw spread switches (those still 409). Server-side: 4-line change in `views.my_sea_lock` — `spread_changed = existing.spread != spread`; `if spread_changed and existing.hand: return 409` (preserves prior behavior for non-empty hands); `if spread_changed: existing.spread = spread; update_fields.append("spread")` in the update block. New IT `MySeaLockHandViewTest.test_lock_post_spread_switch_after_del_succeeds` exercises the full flow: first POST creates row w. spread=SAO + 1 card; DEL clears hand; second POST w. spread=waite-smith + different card → 200 + row.spread is now "waite-smith" + row.created_at unchanged. Existing `test_lock_post_spread_mismatch_within_quota_returns_409` test docstring updated to clarify the new policy ("for the duration of an ACTIVE non-empty draw"); the 409 assertion still holds for its specific scenario (mid-non-empty-draw switch). Tests: 1 new IT green (lock-view spread-switch-after-DEL); 14/14 MySeaLockHandViewTest class green; 1307/1307 IT+UT total green (74s; +1 from 26cdf0d's 1306). Memory: `project_image_based_deck_face_rendering.md` has the detailed AUTO DRAW root-cause writeup; tomorrow's A.8 work is the only remaining image-rendering surface Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:43:00 -04:00
# Spread-switch policy (refined 2026-05-25 PM per user bug report —
# AUTO DRAW failing on non-SAO spreads with 409): the spread is
# committed at first-card moment for the duration of the active
# NON-EMPTY draw. Once the user DELs (clears hand to []) the row
# stays alive for its 24h quota window but the spread lock lifts —
# the user can pick a fresh spread for the next draw without losing
# the cooldown clock. Mid-non-empty-draw spread switches still get
# 409 to prevent the "sneaky POST" of a different spread without an
# explicit DEL gesture.
spread_changed = existing.spread != spread
if spread_changed and existing.hand:
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
return JsonResponse({"error": "spread_mismatch"}, status=409)
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>
2026-05-23 15:06:35 -04:00
# 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
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
existing.hand = hand
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>
2026-05-23 15:06:35 -04:00
update_fields = ["hand"]
A.7-polish-3 stat-block title + arcana fields across 3 surfaces + spread-switch unlock after DEL — TDD. End-of-session 2026-05-25 PM. Two changes bundled (both user-requested as round-out work for tonight's final push): 1. Stat-block restructure per [[project-image-based-deck-face-rendering]]'s locked Q3 spec. User noticed during browser verify that image-mode card surfaces (Minchiate-equipped on my_sign main stage + My Sign applet + Sea Stage modal in my_sea) show only the EMANATION label in the stat-block — no title, no arcana type, no keywords (Minchiate keywords are empty per A.1 seed). For text-mode decks (Earthman, RWS) the title + arcana render ON the card; for image-mode the card is just an image so all textual metadata MUST move to the stat-block. Spec was originally written for image-mode only but user explicitly asked for it universally so non-image cards also get the info in the stat-block (visible duplicates w. card content — acceptable tradeoff per user). Implementation: added `<p class="stat-face-title">` + `<p class="stat-face-arcana">` to both upright + reversed `.stat-face` blocks in `my_sign.html` (line ~58) + `_sea_stage.html` (the modal). For `_applet-my-sign.html` (no SPIN btn, no reversed face), rendered server-side from `card.name` + `card.get_arcana_display` + the new `data-arcana-key="{{ card.arcana }}"` attr on the stat-block wrapper. New JS helper `StageCard.populateStatExtras(statBlock, card, opts)` in `stage-card.js` parallels the existing `populateKeywords` — fills `.stat-face-title` (card name minus any "Title, Qualifier" Earthman pattern stripping) + `.stat-face-arcana` (`_arcanaDisplay(card)` reused) + sets `data-arcana-key` on the stat-block parent for SCSS color-keying. Exported alongside populateKeywords; called from `my_sign.html` inline + `sea.js`'s `_populate` (the 2 dynamic stat-block sites). `sig-select.js` (room sig select) intentionally NOT updated — that's A.8 territory + its stat block markup differs. SCSS: extended `stat-block-shared` mixin in `_card-deck.scss` w. `.stat-face-title` (font-weight 700, color --quiUser default; `[data-arcana-key="MAJOR"]` selector flips to --terUser matching the contour-stroke arcana-color convention) + `.stat-face-arcana` (uppercase letter-spaced like `.stat-face-label`). Same rules duplicated in `_billboard.scss` `.my-sign-applet-stat-block` block (different sizing via `--applet-card-w` container query). `:empty` rule hides both title + arcana when JS hasn't populated yet (rest state — prevents zero-height paragraphs inflating the stat block). Also added user-spec'd underline to `.stat-face-label` (text-decoration: underline + 0.15em offset). 2. Spread-switch policy unlock after DEL. User-reported AUTO DRAW failure ("only works with default SOA spread, others give visual click feedback but no cards") + spread-switch failure ("won't persist with a partial draw either, only SAO will"). Investigated: root cause is `views.my_sea_lock` line 372 returning `409 spread_mismatch` whenever the POST's spread != the existing `MySeaDraw` row's spread. The row spread is committed at first-card moment and `active_draw_for` returns rows for 24h regardless of hand state (even empty post-DEL rows still hold the spread lock). Combobox switches visually but every subsequent POST 409s. Refined policy: spread is locked only during an ACTIVE non-empty draw. Once the user DELs (clears hand to []), the spread lock lifts — a POST w. a different spread UPDATES the existing row's spread + populates the new hand. The 24h quota window (created_at + paid_through_at) is preserved so the cooldown clock stays put. Sneaky-POST mitigation is still in effect for mid-non-empty-draw spread switches (those still 409). Server-side: 4-line change in `views.my_sea_lock` — `spread_changed = existing.spread != spread`; `if spread_changed and existing.hand: return 409` (preserves prior behavior for non-empty hands); `if spread_changed: existing.spread = spread; update_fields.append("spread")` in the update block. New IT `MySeaLockHandViewTest.test_lock_post_spread_switch_after_del_succeeds` exercises the full flow: first POST creates row w. spread=SAO + 1 card; DEL clears hand; second POST w. spread=waite-smith + different card → 200 + row.spread is now "waite-smith" + row.created_at unchanged. Existing `test_lock_post_spread_mismatch_within_quota_returns_409` test docstring updated to clarify the new policy ("for the duration of an ACTIVE non-empty draw"); the 409 assertion still holds for its specific scenario (mid-non-empty-draw switch). Tests: 1 new IT green (lock-view spread-switch-after-DEL); 14/14 MySeaLockHandViewTest class green; 1307/1307 IT+UT total green (74s; +1 from 26cdf0d's 1306). Memory: `project_image_based_deck_face_rendering.md` has the detailed AUTO DRAW root-cause writeup; tomorrow's A.8 work is the only remaining image-rendering surface Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:43:00 -04:00
if spread_changed:
existing.spread = spread
update_fields.append("spread")
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>
2026-05-23 15:06:35 -04:00
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)
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
return JsonResponse({
"ok": True,
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>
2026-05-23 15:06:35 -04:00
"next_free_draw_at": (
request.user.next_free_draw_at.isoformat()
if request.user.next_free_draw_at else None
),
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
"hand_complete": existing.is_hand_complete,
})
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
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>
2026-05-23 15:06:35 -04:00
# 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).
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
sig_id = request.user.significator_id
if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400)
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>
2026-05-23 15:06:35 -04:00
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"])
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
draw = MySeaDraw.objects.create(
user=request.user,
spread=spread,
hand=hand,
significator_id=sig_id,
significator_reversed=request.user.significator_reversed,
)
return JsonResponse({
"ok": True,
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>
2026-05-23 15:06:35 -04:00
"next_free_draw_at": (
request.user.next_free_draw_at.isoformat()
if request.user.next_free_draw_at else None
),
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
"hand_complete": draw.is_hand_complete,
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
})
@login_required(login_url="/")
@require_POST
def my_sea_delete(request):
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
so the 24h quota window keeps running. Per user spec (2026-05-20):
DEL doesn't refund the daily free-draw; the row stays as a quota
tracker until 24h elapse, after which `active_draw_for`'s lazy
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
draw = active_draw_for(request.user)
if draw is not None:
draw.hand = []
draw.save(update_fields=["hand"])
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
return HttpResponse(status=204)
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
@login_required(login_url="/")
def my_sea_gate(request):
My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + `_select_my_sea_token` / `debit_my_sea_token` helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
"""Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's
`_gatekeeper.html` structure (coin-slot rails + refund affordance)
adapted for 1-user, 1-token-per-redraw semantics. The user spends a
token (PASS/COIN/FREE/TITHE CARTE excluded) to acquire a fresh
24h quota cycle after their daily free draw is spent.
Branches on `MySeaDraw.deposit_token_id`:
- None (no deposit yet) INSERT TOKEN TO PLAY rails are active.
- non-None (deposit reserved) refund affordance + PAID DRAW btn.
"""
active_draw = active_draw_for(request.user)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
deposit_reserved = (
active_draw is not None and active_draw.deposit_token_id is not None
)
hand_non_empty = active_draw is not None and bool(active_draw.hand)
return render(request, "apps/gameboard/my_sea_gate.html", {
"user_has_sig": sig_card is not None,
"significator": sig_card,
"significator_reversed": sig_reversed,
"active_draw": active_draw,
"deposit_reserved": deposit_reserved,
"hand_non_empty": hand_non_empty,
"page_class": "page-gameboard page-my-sea page-my-sea-gate",
})
@login_required(login_url="/")
@require_POST
def my_sea_insert_token(request):
"""Reserve the user's next-priority token on their MySeaDraw row.
Idempotent w.r.t. an already-reserved deposit re-posting is a no-op
rather than double-debit. Creates the row if none exists (so a fresh
user can hit the gatekeeper without first using their free draw)."""
active_draw = active_draw_for(request.user)
if active_draw is None:
# No active row yet — create a quota tracker row w. empty hand
# so the deposit has something to attach to. This also commits
# the user's free-draw quota for the day (since `active_draw_
# for` will now return this row).
sig_id = request.user.significator_id
if sig_id is None:
return redirect("my_sea_gate")
active_draw = MySeaDraw.objects.create(
user=request.user,
spread="situation-action-outcome",
hand=[],
significator_id=sig_id,
significator_reversed=request.user.significator_reversed,
)
if active_draw.deposit_token_id is not None:
return redirect("my_sea_gate")
token = _select_my_sea_token(request.user)
if token is None:
return redirect("my_sea_gate")
active_draw.deposit_token_id = token.pk
active_draw.deposit_reserved_at = timezone.now()
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
return redirect("my_sea_gate")
@login_required(login_url="/")
@require_POST
def my_sea_refund_token(request):
"""Clear the user's deposit reservation. Token wasn't actually
debited at INSERT (refund-aware design), so this is purely a row
update no side effects on the user's inventory."""
active_draw = active_draw_for(request.user)
if active_draw is not None and active_draw.deposit_token_id is not None:
active_draw.deposit_token_id = None
active_draw.deposit_reserved_at = None
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
return redirect("my_sea_gate")
@login_required(login_url="/")
@require_POST
def my_sea_paid_draw(request):
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>
2026-05-23 15:06:35 -04:00
"""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).
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
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>
2026-05-23 15:06:35 -04:00
directly in the picker after the commit.
"""
from django.urls import reverse
My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + `_select_my_sea_token` / `debit_my_sea_token` helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
from apps.lyric.models import Token
active_draw = active_draw_for(request.user)
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>
2026-05-23 15:06:35 -04:00
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")
My Sea iter 6a: gatekeeper page + INSERT/REFUND/PAID DRAW endpoints + MySeaDraw deposit fields + `_select_my_sea_token` / `debit_my_sea_token` helpers (CARTE blocked, COIN 24h cooldown not 7-day) + Sprint 6 FT skeleton — Sprint 5 iter 6a of My Sea roadmap — TDD First of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Replaces the iter-4c 404 stub at `/gameboard/my-sea/gate/` w. a real token-deposit-to-redraw UI. Iter 6b will wire the navbar GATE VIEW swap + landing PAID DRAW state + seat-1 persistence; iter 6c will land the bud-btn stub. ## Server `MySeaDraw` gains two fields: `deposit_token_id` (int, nullable) + `deposit_reserved_at` (datetime, nullable). Migration 0002. The row plays triple duty now: hand storage + 24h quota tracker + deposit reservation slot. `_select_my_sea_token(user)` mirrors `apps.epic.models.select_token` priority (PASS > COIN > FREE > TITHE) w. two adaptations: - CARTE excluded outright (door-spell trinket, not valid for my-sea draws). - COIN cooldown-respecting: filters out COINs w. `next_ready_at > now`. Standard `select_token` doesn't apply this filter — room logic unchanged. `debit_my_sea_token(user, token)` is the my-sea variant of `apps.epic.models.debit_token`: - CARTE → ValueError (defensive; caller validates upstream). - COIN: `next_ready_at = now + 24h` (not 7-day room cycle) + unequip from kit if equipped. - PASS: no consumption (auto-admit, unlimited redraws). - FREE / TITHE: deleted. `my_sea_gate` view replaces the 404 stub. Renders the gatekeeper template w. branching on `deposit_reserved` (token reserved on row vs not). `my_sea_insert_token` POST: picks a token via `_select_my_sea_token` + sets `deposit_token_id + deposit_reserved_at`. Creates the row if missing (so a fresh user can deposit without first using their free draw). Idempotent w.r.t. an already-reserved deposit. `my_sea_refund_token` POST: clears deposit fields. Token isn't consumed at INSERT (refund-aware design), so this is purely a row update — no inventory side effects. `my_sea_paid_draw` POST: commits via `debit_my_sea_token` + resets row (hand=[], created_at=now, deposit fields cleared). Redirects to `/gameboard/my-sea/` for a fresh quota cycle. ## Template + UX `apps/gameboard/my_sea_gate.html` (new) — per user spec 2026-05-20, the gatekeeper is a darkened-modal-over-`--duoUser` bg matching the room gatekeeper's chrome (`.gate-backdrop` + `.gate-overlay` + `.gate-modal`). No hex / chair-seats — those live on the my-sea picker page itself; the gatekeeper is a transient in-flight UI for token deposit. Coin-slot rails (mirrors room's `.token-slot`): - Pre-deposit: form-wrapped `.token-rails` button → POSTs to `my_sea_insert_token`. Coin-panel labels read INSERT TOKEN TO PLAY. - Post-deposit: rails inert (no form); `.token-return-btn` form → POSTs to `my_sea_refund_token`. Coin-panel labels swap to PUSH TO RETURN. - Post-deposit: PAID DRAW btn (`#id_my_sea_paid_draw_btn`, `.btn-primary`) → POSTs to `my_sea_paid_draw`. Mirrors the room's PICK ROLES btn shape. SCSS minimal — page bg `rgba(--duoUser, 1)` on `.my-sea-page[data-phase="gate"]`; everything else reuses the room gatekeeper's existing rules. ## FT skeleton Per user TDD directive (2026-05-20: "Also via TDD so if we run out we're adhering to FT-described behavior"), wrote the FULL Sprint 6 FT skeleton up front (covers iter 6a + 6b + 6c). Five new FT classes in `test_game_my_sea.py`: - `MySeaGatekeeperPageTest` (5 tests) — iter 6a; pre-deposit / INSERT / REFUND / PAID DRAW paths. - `MySeaLandingPaidDrawTest` (1 test) — iter 6b; landing renders PAID DRAW btn when deposit reserved (red until iter 6b lands). - `MySeaNavbarGateViewTest` (1 test) — iter 6b; navbar GATE VIEW swap (red until iter 6b). - `MySeaSeatOnePersistenceTest` (2 tests) — iter 6b; seat 1 banned for fresh user + empty-hand active draw (red until iter 6b). - `MySeaBudBtnStubTest` (2 tests) — iter 6c; panel opens + OK shows coming-soon Brief (red until iter 6c). ## ITs (iter 6a — 22 new + 153 total green) - `MySeaGateViewTest` (4) — view branching pre/post deposit. - `MySeaInsertTokenViewTest` (4) — row creation, existing row, idempotency, GET=405. - `MySeaRefundTokenViewTest` (3) — clears fields, no token consumption, idempotent. - `MySeaPaidDrawViewTest` (6) — FREE consumed, COIN cooldown + unequip, PASS no-op, hand reset, created_at reset, redirect. - `SelectMySeaTokenTest` (3) — CARTE excluded, COIN cooldown excluded, PASS priority for staff. - `DebitMySeaTokenTest` (4) — CARTE ValueError, FREE/TITHE consumed, PASS preserved. ## Trap caught Existing User `post_save` signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`). Sprint 6 ITs that assert "user has only the token I seeded" must `self.user.tokens.all().delete()` after User.create. Without it, `_select_my_sea_token` returns the auto-COIN instead of None for the CARTE-excluded test. Worth a future feedback memory if it bites again. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 02:29:08 -04:00
return redirect("my_sea")
token = Token.objects.filter(
pk=active_draw.deposit_token_id, user=request.user,
).first()
if token is None:
# Token vanished between reserve + commit (unlikely w. solo
# flow but defensive). Clear deposit + bounce to my-sea.
active_draw.deposit_token_id = None
active_draw.deposit_reserved_at = None
active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
return redirect("my_sea")
debit_my_sea_token(request.user, token)
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>
2026-05-23 15:06:35 -04:00
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")
@login_required(login_url="/")
@require_POST
def my_sea_invite(request):
"""Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper.
Async multi-user invite is deferred to a later sprint; this endpoint
just returns a Brief banner announcing "coming soon" so the bud-btn
panel has a non-broken success path."""
from django.urls import reverse
return JsonResponse({
"brief": {
"title": "Multiplayer my-sea",
"line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.",
"post_url": reverse("gameboard"),
"created_at": "",
"kind": "NUDGE",
},
"recipient_display": (request.POST.get("recipient") or "").strip(),
})
My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running. ## Server `MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker. - `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS). - `is_hand_complete` / `is_hand_empty` props drive view branching + template button states. - `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop). - `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn"). `my_sea_lock` is now a true upsert: - First POST creates the row (quota commit). - Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists). - Spread-mismatch (attempted spread switch within quota window) → 409. - Empty/malformed hand → 400. - Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions. `my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent. `my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX. `my_sea` view branches: 1. No sig → sign-gate 2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand 3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn 4. No active draw → landing phase w. FREE DRAW btn ## Template + UX - Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`: - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists). - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6). - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion). - DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing. - Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag. - Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish. ## Other polish bundled - **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity. - **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression. - **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg." - **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw). ## Test coverage - ITs (1100 IT/UT green in 57s): - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`. - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response. - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test). - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state. - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW. - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled. - `MySeaGateStubViewTest` (new) — 404 stub + login required. - FTs (35 my_sea FTs green in 5m): - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW). - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`. - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`. - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic). - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion). - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:34:03 -04:00
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
def _my_sea_deck_data(user, exclude_id=None):
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
"""Build the shuffled deck (levity + gravity halves) for the my-sea
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
picker's card-draw mechanic. Card payload shape is whatever
`apps.epic.utils.card_dict` defines (single source of truth shared
w. the gameroom `sea_deck` endpoint).
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
Differences from the room version:
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
- No `room` context exclude only the sig card (no other seated
gamers to worry about). `exclude_id` defaults to `user.significator_id`
but callers can pass a draw's snapshotted sig id when the saved-
draw branch is rendering.
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
fall back to Earthman (mirrors `personal_sig_cards`).
"""
import random
from apps.epic.models import DeckVariant, TarotCard
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
from apps.epic.utils import card_dict, stack_reversal_probability
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
if not deck:
return {"levity": [], "gravity": []}
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
if exclude_id is None:
exclude_id = user.significator_id
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
available = list(TarotCard.objects.filter(deck_variant=deck))
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
if exclude_id:
available = [c for c in available if c.id != exclude_id]
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
random.shuffle(available)
mid = len(available) // 2
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
reversal_prob = stack_reversal_probability(user)
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
return {
My Sea iter 4b: MySeaDraw persistence + LOCK HAND POST + DEL guard + Brief banner; rewrite obsolete spread-switch FT; fix bud-panel CI race on gatekeeper FT — Sprint 5 iter 4b of My Sea roadmap — TDD Iter 4b lands server persistence of the iter-4a client-side hand. New MySeaDraw model (FK user, spread, hand JSONField in draw order, sig snapshot, created_at) w. 1/24h quota window; new endpoints /gameboard/my-sea/lock (POST, 409 on quota-active, 400 on partial hand) + /gameboard/my-sea/delete (POST, idempotent). LOCK HAND now collects the in-progress hand from DOM, POSTs, and on success un-hides a Brief banner inline (no page reload — preserves iter-4a FT picker refs). DEL post-LOCK opens #id_my_sea_del_portal w. uniform 'Are you sure?' copy; CONFIRM POSTs delete + reloads to landing. Brief banner carries the next-free-draw timestamp + a NVM dismiss. Saved-draw render bypasses the sign-gate via _resolve_sig (sig snapshot on the draw is used even if user.significator was cleared later) + bypasses the landing phase (the saved hand IS what the user came to see). Per-position slot rendering extracted to _my_sea_slot.html. DRY follow-up: card_dict() extracted to apps.epic.utils — gameroom sea_deck + my-sea _my_sea_deck_data now share one source of truth (prevents drift like the iter-4a-follow-up Major Arcana fix from recurring). Pipeline #316 fixes bundled: (a) functional_tests.test_game_my_sea.MySeaCardDrawTest.test_switching_spread_resets_in_progress_hand was obsoleted by the iter-4a follow-up's spread-lock-after-first-draw — the test premise (mid-draw spread switching resets hand) no longer matches behavior (switching is blocked outright). Rewrote as test_first_draw_locks_spread_combobox, which pins .sea-select--locked after first draw + verifies DEL releases it. (b) functional_tests.test_game_room_gatekeeper.GatekeeperTest.test_second_gamer_drops_token_into_open_slot failed in CI on ElementNotInteractableException when clicking #id_bud_panel .btn.btn-confirm — the bud panel's scaleX(0)→scaleX(1) 0.2s CSS transition wasn't settled by click-time, so Selenium read scroll-into-view against a near-zero-width target. Added a wait_for on getBoundingClientRect().width > 100 so the click waits for the animation to finish. Local passes consistently; CI was 1+ frame slower than the implicit 'find element' wait. Tests: 1085 IT/UT green in 55s; 35 my_sea FTs green in 5m; new ITs in MySeaDrawModelTest (8), MySeaLockHandViewTest (7), MySeaDeleteDrawViewTest (5), MySeaViewWithSavedDrawTest (9); new FTs in MySeaLockHandTest (5). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:54:00 -04:00
"levity": [card_dict(c, reversal_prob) for c in available[:mid]],
"gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b. **Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock: - No `room` context; excludes only the **current user's significator** (no other seated gamers). - Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working. - Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.) Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL). `.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there. **FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP): - deck JSON embedded w. two polarity halves, disjoint card ids; - user significator excluded from both halves; - first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside; - second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected; - 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops; - DEL resets every filled slot, LOCK HAND re-disables; - LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks; - switching to MBS mid-draw wipes the in-progress hand. **ITs** (6 in new `MySeaDeckDataViewTest`): - context `sea_deck_data` has `levity` + `gravity` keys, both lists; - user significator absent from both halves; - halves are disjoint sets of card ids; - card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract; - template embeds via `<script id="id_my_sea_deck" type="application/json">`; - no-equipped-deck users get the Earthman backup pile (not empty). Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s. **Deferred to iter 4b** (server persistence): - `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at); - LOCK HAND POST endpoint → commits the hand to the DB; - 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap; - Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
}
@login_required(login_url="/")
def tarot_fan(request, deck_id):
from apps.epic.models import TarotCard
deck = get_object_or_404(DeckVariant, pk=deck_id)
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
return HttpResponse(status=403)
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
_suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3}
cards = sorted(
TarotCard.objects.filter(deck_variant=deck),
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
)
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
"deck": deck,
"cards": cards,
})