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 `×` 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>
This commit is contained in:
0
src/apps/gameboard/management/__init__.py
Normal file
0
src/apps/gameboard/management/__init__.py
Normal file
0
src/apps/gameboard/management/commands/__init__.py
Normal file
0
src/apps/gameboard/management/commands/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Hard-delete MySeaDraw rows older than FREE_DRAW_COOLDOWN_HOURS.
|
||||
|
||||
The lazy cleanup inside `apps.gameboard.models.active_draw_for` already
|
||||
prunes a user's stale rows on every view hit; this command is the cron
|
||||
backstop for inactive accounts whose rows would otherwise sit forever.
|
||||
|
||||
Run from cron (or invoke manually). No flags; idempotent.
|
||||
|
||||
Usage:
|
||||
python manage.py delete_stale_my_sea_draws
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Delete MySeaDraw rows older than the free-draw cooldown window (24h)."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
n = MySeaDraw.delete_stale()
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Deleted {n} stale MySeaDraw row{'s' if n != 1 else ''}."
|
||||
))
|
||||
@@ -5,28 +5,45 @@ from django.utils import timezone
|
||||
|
||||
FREE_DRAW_COOLDOWN_HOURS = 24
|
||||
|
||||
# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS.
|
||||
# Used by `is_hand_complete` to decide whether the picker UX is in
|
||||
# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled)
|
||||
# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state.
|
||||
HAND_SIZE_BY_SPREAD = {
|
||||
"past-present-future": 3,
|
||||
"situation-action-outcome": 3,
|
||||
"mind-body-spirit": 3,
|
||||
"desire-obstacle-solution": 3,
|
||||
"waite-smith": 6,
|
||||
"escape-velocity": 6,
|
||||
}
|
||||
|
||||
|
||||
class MySeaDraw(models.Model):
|
||||
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
|
||||
feature. Each row is one locked hand by one user.
|
||||
feature. Each row is the user's active draw — both the hand state
|
||||
AND the 24h quota tracker.
|
||||
|
||||
Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side
|
||||
persistence of the iter-4a client-side draw mechanic.
|
||||
|
||||
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
|
||||
(24h, irrespective of spread type). Subsequent draws within the
|
||||
window are intended to be gated behind a token deposit at the My
|
||||
Sea gatekeeper, which Sprint 6 will build.
|
||||
Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
|
||||
iter-4b LOCK-HAND model. The row is created on the FIRST card draw
|
||||
(manual or AUTO DRAW), not on a separate LOCK action. Quota is
|
||||
committed at first-card moment + survives a DEL (DEL clears the
|
||||
`hand` JSON but preserves the row — so `created_at` keeps running
|
||||
the 24h clock + landing renders GATE VIEW instead of FREE DRAW
|
||||
until the row expires).
|
||||
|
||||
`hand` is an ordered list of position-dicts in draw order — Sprint 7's
|
||||
applet renders them left-to-right in that order. Each entry shape:
|
||||
|
||||
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
|
||||
|
||||
For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD).
|
||||
For DEL'd rows, `hand` is `[]` but `created_at` still anchors the
|
||||
quota window.
|
||||
|
||||
`significator_id` + `significator_reversed` snapshot the user's sig
|
||||
at lock time so a subsequent `User.significator = None` (via my-sign
|
||||
DEL) doesn't invalidate the saved draw — per user spec, preserve the
|
||||
old sig; any future draw uses whatever sig is current at that time.
|
||||
at first-card-draw time so a subsequent `User.significator = None`
|
||||
(via my-sign DEL) doesn't invalidate the saved draw.
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
|
||||
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_hand_complete(self):
|
||||
"""True iff the hand has filled every position for its spread.
|
||||
Drives the picker-form-col button state (AUTO DRAW → GATE VIEW
|
||||
+ DEL enable + FLIPs disable on the deck stacks)."""
|
||||
return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0)
|
||||
|
||||
@property
|
||||
def is_hand_empty(self):
|
||||
"""True iff the hand carries no entries — the post-DEL state,
|
||||
where the row stays as a quota tracker but the cards are gone."""
|
||||
return not self.hand
|
||||
|
||||
@classmethod
|
||||
def delete_stale(cls):
|
||||
"""Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS.
|
||||
Returns the deletion count.
|
||||
|
||||
Called lazily from `active_draw_for` on every view access (so
|
||||
the cleanup naturally rides user traffic — no scheduler needed
|
||||
for the common case) AND from the `delete_stale_my_sea_draws`
|
||||
management command (cron backstop for inactive periods)."""
|
||||
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete()
|
||||
return deleted
|
||||
|
||||
|
||||
def active_draw_for(user):
|
||||
"""Return the user's most-recent draw within the quota window, or
|
||||
None. Used both for rendering the picker w. saved hand on page load
|
||||
and for gating LOCK HAND POSTs.
|
||||
None. Single source of truth for "does the user have an active draw"
|
||||
(drives view branching: no-active → landing w. FREE DRAW; active w.
|
||||
empty hand → landing w. GATE VIEW; active w. non-empty hand → picker).
|
||||
|
||||
Importing this helper rather than re-deriving the cutoff in every
|
||||
caller keeps the 24h window a single-source-of-truth tied to
|
||||
FREE_DRAW_COOLDOWN_HOURS."""
|
||||
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
|
||||
the DB doesn't accumulate one row per user per day. The user-spec
|
||||
'auto-delete all draws after 24hrs, whether or not the user has
|
||||
deleted them' (2026-05-20) lands here w. no scheduler required."""
|
||||
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
|
||||
return MySeaDraw.objects.filter(
|
||||
user=user, created_at__gte=cutoff,
|
||||
).order_by("-created_at").first()
|
||||
|
||||
@@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase):
|
||||
self.assertIn('data-position="loom"></span>', html)
|
||||
self.assertIn('data-position="cross"></span>', html)
|
||||
|
||||
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
|
||||
def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self):
|
||||
# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
|
||||
# transitions to GATE VIEW on completion. ID `id_sea_action_btn`
|
||||
# is the single slot housing both states (label + `data-state`
|
||||
# toggled by JS). User w. no active draw → AUTO DRAW label.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
self.assertIn("sea-deck-stack--gravity", html)
|
||||
self.assertIn("sea-deck-stack--levity", html)
|
||||
self.assertIn('id="id_sea_lock_hand"', html)
|
||||
self.assertIn('id="id_sea_action_btn"', html)
|
||||
self.assertIn('data-state="auto-draw"', html)
|
||||
self.assertIn("AUTO", html)
|
||||
self.assertIn('id="id_sea_del"', html)
|
||||
self.assertIn("sea-reversal-hint", html)
|
||||
self.assertIn("25% reversals", html)
|
||||
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
|
||||
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
||||
self.assertIsNotNone(parsed)
|
||||
|
||||
def test_lock_post_within_quota_window_returns_409(self):
|
||||
# Second lock within 24h: the existing draw already occupies the
|
||||
# quota; the server rejects rather than overwriting.
|
||||
def test_lock_post_within_quota_upserts_same_row(self):
|
||||
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
||||
# Second POST w. same spread updates the existing row's hand
|
||||
# rather than 409'ing. Only one row exists per user per 24h.
|
||||
import json
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
from apps.epic.models import TarotCard
|
||||
# First POST: 1-card partial hand
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||||
partial = {
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||||
}
|
||||
r1 = self.client.post(
|
||||
self.url, data=json.dumps(partial),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
# Second POST: full 3-card hand (the SAME draw progressing).
|
||||
full = self._build_payload()
|
||||
r2 = self.client.post(
|
||||
self.url, data=json.dumps(full),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
# Exactly one MySeaDraw row exists; hand is the latest full one.
|
||||
rows = MySeaDraw.objects.filter(user=self.user)
|
||||
self.assertEqual(rows.count(), 1)
|
||||
self.assertEqual(len(rows.first().hand), 3)
|
||||
|
||||
def test_lock_post_spread_mismatch_within_quota_returns_409(self):
|
||||
# Spread is committed at first-card moment; switching to a
|
||||
# different spread mid-quota-window is rejected.
|
||||
import json
|
||||
from apps.epic.models import TarotCard
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
|
||||
self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
self.url, data=json.dumps({
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
response = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
self.url, data=json.dumps({
|
||||
"spread": "waite-smith",
|
||||
"hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_lock_post_returns_hand_complete_flag(self):
|
||||
# Body includes `hand_complete` so the JS can decide whether to
|
||||
# transition the picker into post-completion state (DEL enable,
|
||||
# FLIPs disable, AUTO DRAW → GATE VIEW).
|
||||
import json
|
||||
partial = {
|
||||
"spread": "situation-action-outcome",
|
||||
"hand": self._build_payload()["hand"][:1],
|
||||
}
|
||||
r1 = self.client.post(
|
||||
self.url, data=json.dumps(partial),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertFalse(r1.json()["hand_complete"])
|
||||
r2 = self.client.post(
|
||||
self.url, data=json.dumps(self._build_payload()),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertTrue(r2.json()["hand_complete"])
|
||||
|
||||
def test_lock_post_empty_hand_returns_400(self):
|
||||
import json
|
||||
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase):
|
||||
|
||||
|
||||
class MySeaDeleteDrawViewTest(TestCase):
|
||||
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
|
||||
"""Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
|
||||
but preserves the row so the 24h quota window keeps running."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_delete_post_clears_active_draw(self):
|
||||
def test_delete_post_clears_hand_but_preserves_row(self):
|
||||
# Iter 4c — DEL no longer deletes; the row stays as a 24h quota
|
||||
# tracker. Hand JSON gets wiped + `created_at` preserved (so the
|
||||
# landing renders GATE VIEW, not FREE DRAW, until the row expires).
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
original_created_at = self.draw.created_at
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
|
||||
self.draw.refresh_from_db()
|
||||
self.assertEqual(self.draw.hand, [])
|
||||
self.assertEqual(self.draw.created_at, original_created_at)
|
||||
self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_delete_post_scoped_to_user_does_not_touch_others(self):
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
|
||||
hand=self.draw.hand, significator_id=self.target.id,
|
||||
)
|
||||
self.client.post(self.url)
|
||||
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
|
||||
other_draw.refresh_from_db()
|
||||
self.assertEqual(len(other_draw.hand), 3) # untouched
|
||||
|
||||
def test_delete_post_idempotent_when_no_active_draw(self):
|
||||
# User deletes twice in a row — second call is a no-op, not a 500.
|
||||
# First DEL clears hand. Second DEL finds the row w. empty hand
|
||||
# already; just no-ops.
|
||||
self.client.post(self.url)
|
||||
response = self.client.post(self.url)
|
||||
self.assertIn(response.status_code, (200, 204, 302))
|
||||
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
self.assertNotContains(response, 'data-phase="landing"')
|
||||
|
||||
def test_complete_hand_renders_action_btn_as_gate_view(self):
|
||||
# Iter 4c — server pre-renders the action btn label + data-state
|
||||
# based on `hand_complete`. With the setUp's 3-card SAO hand,
|
||||
# hand is complete → btn label is GATE VIEW.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-state="gate-view"')
|
||||
self.assertContains(response, "GATE")
|
||||
self.assertContains(response, "VIEW")
|
||||
|
||||
def test_complete_hand_picker_carries_locked_class(self):
|
||||
# `.my-sea-picker--locked` is server-rendered for completed hands
|
||||
# so the JS init seeds `_locked=true` w.o. waiting for the post-
|
||||
# placement state transition (matters for hot reloads, bfcache).
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, "my-sea-picker--locked")
|
||||
|
||||
def test_complete_hand_del_btn_is_not_disabled(self):
|
||||
# DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
|
||||
# hand → DEL renders w.o. the disabled class (clicking opens the
|
||||
# guard portal).
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(
|
||||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||||
)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertNotIn("btn-disabled", m.group(1))
|
||||
|
||||
|
||||
class MySeaViewWithEmptyHandTest(TestCase):
|
||||
"""Sprint 5 iter 4c — view branch for an active draw w. empty hand
|
||||
(the post-DEL state, where the quota row stays as a 24h tracker but
|
||||
the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
|
||||
FREE DRAW) as the primary nav."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="empty@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
# Active draw row but hand is empty — simulates the post-DEL state.
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id, hand=[],
|
||||
)
|
||||
|
||||
def test_empty_hand_renders_landing_phase_not_picker(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-phase="landing"')
|
||||
|
||||
def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
|
||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||
|
||||
def test_empty_hand_gate_view_btn_links_to_gate_url(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, reverse("my_sea_gate"))
|
||||
|
||||
def test_empty_hand_brief_banner_still_triggered(self):
|
||||
# Quota's still committed (row exists, 24h clock still running) →
|
||||
# the Brief banner is part of the saved-draw context, regardless
|
||||
# of hand state. Informs the user when the next free draw is.
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
|
||||
|
||||
|
||||
class MySeaViewWithPartialHandTest(TestCase):
|
||||
"""Sprint 5 iter 4c — view branch for an active draw w. mid-progress
|
||||
hand (some slots filled, not yet complete). Picker renders w. the
|
||||
partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards, TarotCard
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self.user = User.objects.create(email="partial@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = self.target
|
||||
self.user.save(update_fields=["significator"])
|
||||
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
|
||||
# SAO is a 3-position spread; partial = 2 cards drawn.
|
||||
self.draw = MySeaDraw.objects.create(
|
||||
user=self.user, spread="situation-action-outcome",
|
||||
significator_id=self.target.id,
|
||||
hand=[
|
||||
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
|
||||
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_partial_hand_renders_picker_phase(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-phase="picker"')
|
||||
|
||||
def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
self.assertContains(response, 'data-state="auto-draw"')
|
||||
self.assertContains(response, "AUTO")
|
||||
|
||||
def test_partial_hand_del_btn_carries_btn_disabled(self):
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(
|
||||
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
|
||||
)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertIn("btn-disabled", m.group(1))
|
||||
|
||||
def test_partial_hand_picker_does_NOT_carry_locked_class(self):
|
||||
# Hand is mid-progress; locked class only applies on completion.
|
||||
import re
|
||||
response = self.client.get(reverse("my_sea"))
|
||||
html = response.content.decode()
|
||||
m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertNotIn("my-sea-picker--locked", m.group(1))
|
||||
|
||||
|
||||
class MySeaGateStubViewTest(TestCase):
|
||||
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
|
||||
a 404 so the template-side GATE VIEW button URL resolves but the
|
||||
actual gatekeeper UX rides Sprint 6."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gate@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_gate_view_returns_404(self):
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_gate_view_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("my_sea_gate"))
|
||||
# @login_required redirects before the 404 path runs.
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -16,5 +16,6 @@ urlpatterns = [
|
||||
path('my-sea/', views.my_sea, name='my_sea'),
|
||||
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
||||
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
|
||||
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from .models import MySeaDraw, active_draw_for
|
||||
from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
|
||||
|
||||
|
||||
def _annotate_deck_in_use(decks, user):
|
||||
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
|
||||
def my_sea(request):
|
||||
"""Shell view for the My Sea standalone page.
|
||||
|
||||
Branches:
|
||||
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. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase
|
||||
w. saved hand + Brief banner + DEL guard portal. The draw's sig
|
||||
snapshot is rendered (NOT user.significator) so a cleared sig
|
||||
elsewhere doesn't invalidate the saved draw.
|
||||
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
|
||||
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
|
||||
click swaps data-phase to picker for a fresh draw.
|
||||
3a. + no equipped deck → also show backup-deck Brief banner.
|
||||
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.
|
||||
"""
|
||||
active_draw = active_draw_for(request.user)
|
||||
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
||||
@@ -194,10 +202,22 @@ def my_sea(request):
|
||||
default_spread = active_draw.spread
|
||||
saved_hand = active_draw.hand
|
||||
next_free_draw_at = active_draw.next_free_draw_at
|
||||
hand_complete = active_draw.is_hand_complete
|
||||
hand_empty = active_draw.is_hand_empty
|
||||
else:
|
||||
default_spread = "situation-action-outcome"
|
||||
saved_hand = []
|
||||
next_free_draw_at = None
|
||||
hand_complete = False
|
||||
hand_empty = True
|
||||
# Picker is the active phase iff the user has a non-empty hand in
|
||||
# progress (or completed). Empty-hand active draws (post-DEL) fall
|
||||
# back to the landing — but render GATE VIEW instead of FREE DRAW
|
||||
# (the daily quota's spent already; landing's primary nav routes to
|
||||
# the upcoming gatekeeper). New users + post-24h users land on the
|
||||
# standard FREE DRAW landing.
|
||||
show_picker = active_draw is not None and not hand_empty
|
||||
quota_spent = active_draw is not None # any active row = quota committed
|
||||
|
||||
# Per-position lookup for the template — keyed by the position slug
|
||||
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
||||
@@ -234,11 +254,14 @@ def my_sea(request):
|
||||
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
||||
if user_has_sig else {"levity": [], "gravity": []}
|
||||
),
|
||||
# Iter 4b
|
||||
# Iter 4b / 4c
|
||||
"active_draw": active_draw,
|
||||
"saved_hand": saved_hand,
|
||||
"saved_by_position": saved_by_position,
|
||||
"next_free_draw_at": next_free_draw_at,
|
||||
"hand_complete": hand_complete,
|
||||
"show_picker": show_picker,
|
||||
"quota_spent": quota_spent,
|
||||
"page_class": "page-gameboard page-my-sea",
|
||||
})
|
||||
|
||||
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_lock(request):
|
||||
"""Persist the user's just-drawn hand as a `MySeaDraw` row.
|
||||
"""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.
|
||||
|
||||
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
|
||||
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
|
||||
400 for malformed payload, 409 if the user is still within the free-
|
||||
draw cooldown window (existing active draw)."""
|
||||
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)
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
@@ -272,10 +304,26 @@ def my_sea_lock(request):
|
||||
hand = payload.get("hand")
|
||||
if not spread or not isinstance(hand, list) or not hand:
|
||||
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
|
||||
if spread not in HAND_SIZE_BY_SPREAD:
|
||||
return JsonResponse({"error": "unknown_spread"}, status=400)
|
||||
|
||||
if active_draw_for(request.user) is not None:
|
||||
return JsonResponse({"error": "quota_active"}, status=409)
|
||||
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).
|
||||
# Spread-switch attempts get 409 — the spread is committed at
|
||||
# first-card moment.
|
||||
if existing.spread != spread:
|
||||
return JsonResponse({"error": "spread_mismatch"}, status=409)
|
||||
existing.hand = hand
|
||||
existing.save(update_fields=["hand"])
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
|
||||
"hand_complete": existing.is_hand_complete,
|
||||
})
|
||||
|
||||
# First card draw → quota commit. Create the row.
|
||||
sig_id = request.user.significator_id
|
||||
if sig_id is None:
|
||||
return JsonResponse({"error": "no_significator"}, status=400)
|
||||
@@ -290,18 +338,35 @@ def my_sea_lock(request):
|
||||
return JsonResponse({
|
||||
"ok": True,
|
||||
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
||||
"hand_complete": draw.is_hand_complete,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_delete(request):
|
||||
"""Delete the user's active draw — invoked by the DEL guard portal's
|
||||
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
|
||||
MySeaDraw.objects.filter(user=request.user).delete()
|
||||
"""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"])
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_sea_gate(request):
|
||||
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
|
||||
button-target placeholder lets the template's GATE VIEW UX wire up
|
||||
in advance; Sprint 6 will replace this w. the token-deposit flow."""
|
||||
return HttpResponse(status=404)
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Card payload shape is whatever
|
||||
|
||||
@@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_enables_when_sao_hand_is_complete(self):
|
||||
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
|
||||
positions are drawn (hand-size = 3 for any three-card spread)."""
|
||||
def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
|
||||
"""Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
|
||||
DRAW (`data-state="auto-draw"`); when the final card lands, JS
|
||||
transitions it to GATE VIEW (`data-state="gate-view"`, label =
|
||||
"GATE VIEW")."""
|
||||
picker = self._enter_picker_phase()
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
# Two draws — still disabled.
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
self._draw_one(picker, "gravity")
|
||||
# Third draw completes the SAO hand — LOCK HAND enables.
|
||||
# Third draw completes the SAO hand — action btn becomes GATE VIEW.
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
|
||||
)
|
||||
self.assertIn("GATE", action_btn.text.upper())
|
||||
|
||||
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_click_resets_hand_and_disables_lock_hand(self):
|
||||
"""DEL fully resets — every filled slot returns to `--empty`,
|
||||
labels re-render, _filled counter zeros, LOCK HAND disables."""
|
||||
def test_del_btn_is_disabled_until_hand_complete(self):
|
||||
"""Iter-4c — DEL btn renders `.btn-disabled` server-side until
|
||||
the hand is complete (per spec: the 24h free-draw quota is
|
||||
committed at first-card-draw, can't be refunded by an early
|
||||
DEL). Once the hand fills, JS removes `.btn-disabled` from DEL."""
|
||||
picker = self._enter_picker_phase()
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
self._draw_one(picker, "levity")
|
||||
# Mid-draw — still disabled.
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
2,
|
||||
)
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
# Hand complete — DEL un-disables (clicking now opens guard portal).
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(picker.find_elements(
|
||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
||||
)),
|
||||
0,
|
||||
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
)
|
||||
)
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
||||
|
||||
# ── Test 7 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_lock_hand_click_disables_further_interaction(self):
|
||||
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
|
||||
itself all carry the `.btn-disabled` class so the hand can't
|
||||
be mutated further. Persistence (POST to a server endpoint)
|
||||
defers to iter 4b — this test pins only the visual lock."""
|
||||
def test_hand_completion_locks_picker_state(self):
|
||||
"""Iter-4c — when the final card lands (manual or AUTO DRAW),
|
||||
the picker gains `.my-sea-picker--locked`; further deck-stack
|
||||
clicks still SHOW the FLIP btn (so the user can see why no
|
||||
further drawing is allowed) but the FLIP carries `.btn-disabled`
|
||||
+ cards no longer fire on its click. No discrete LOCK HAND
|
||||
action; the transition is automatic on hand-completion."""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "levity")
|
||||
self._draw_one(picker, "gravity")
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.wait_for(
|
||||
lambda: self.assertIsNone(lock.get_attribute("disabled"))
|
||||
)
|
||||
lock.click()
|
||||
# Picker carries a .my-sea-picker--locked class after LOCK HAND.
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
||||
)
|
||||
)
|
||||
# Swatches no longer respond — clicking them does nothing.
|
||||
gravity_stack = picker.find_element(
|
||||
By.CSS_SELECTOR, ".sea-deck-stack--gravity"
|
||||
)
|
||||
self.assertIn("btn-disabled", gravity_stack.get_attribute("class"))
|
||||
|
||||
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_first_draw_locks_spread_combobox(self):
|
||||
"""Per the iter-4a follow-up spec lock (2026-05-19): once the
|
||||
first card lands, the SPREAD combobox carries `.sea-select--
|
||||
locked` so mid-draw spread switching is prevented (it would
|
||||
scramble the position→card mapping). DEL releases the lock.
|
||||
|
||||
Was previously `test_switching_spread_resets_in_progress_hand`
|
||||
— that test's premise (mid-draw spread switch resets hand) is
|
||||
obsolete now that switching is blocked outright."""
|
||||
"""Iter-4c — once the first card lands, the SPREAD combobox
|
||||
carries `.sea-select--locked` for the rest of the quota window.
|
||||
The spread is committed at first-card moment (server-side too:
|
||||
any later POST w. a different spread → 409); no client-side
|
||||
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
|
||||
`.btn-disabled` pre-completion → no reset pathway.)"""
|
||||
picker = self._enter_picker_phase()
|
||||
self._draw_one(picker, "levity")
|
||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||
self.wait_for(
|
||||
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
# DEL unlocks (mirrors `_resetHand` calling `_unlockSpread`).
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
delbtn.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotIn("sea-select--locked", combo.get_attribute("class"))
|
||||
)
|
||||
|
||||
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest):
|
||||
)
|
||||
self.assertIn("GRAVITY", names)
|
||||
self.assertIn("LEVITY", names)
|
||||
# LOCK HAND + DEL
|
||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
||||
self.assertIn("LOCK", lock.text.upper())
|
||||
self.assertIn("HAND", lock.text.upper())
|
||||
self.assertIn("btn-primary", lock.get_attribute("class"))
|
||||
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
|
||||
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||
self.assertIn("AUTO", action_btn.text.upper())
|
||||
self.assertIn("btn-primary", action_btn.get_attribute("class"))
|
||||
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
||||
self.assertIn("DEL", delbtn.text.upper())
|
||||
# DEL renders w. `.btn-disabled` pre-completion (the `×` overlay
|
||||
# is CSS-only; raw text content is still "DEL" in the DOM).
|
||||
# Assert on class state — `.text` returns the visible glyph
|
||||
# rendered by the pseudo-element layer.
|
||||
self.assertIn("btn-danger", delbtn.get_attribute("class"))
|
||||
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||
# Reversal % caption — default 25
|
||||
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
||||
self.assertIn("25", hint.text)
|
||||
@@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
|
||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
|
||||
"""Clicking the portal's OK (`.guard-yes`) POSTs to the delete
|
||||
endpoint → server wipes the MySeaDraw row → reload lands on the
|
||||
FREE DRAW landing again (no saved hand, no Brief banner, FREE
|
||||
DRAW btn present)."""
|
||||
def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
|
||||
"""Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
|
||||
POSTs to the delete endpoint → server CLEARS the hand JSON but
|
||||
preserves the MySeaDraw row (quota tracker stays running for the
|
||||
24h window). Reload lands on the table-hex landing — but the
|
||||
primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT
|
||||
FREE DRAW, since the quota's spent until the row expires."""
|
||||
from apps.gameboard.models import MySeaDraw
|
||||
self._save_draw_for_user()
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
|
||||
@@ -1184,4 +1168,13 @@ class MySeaLockHandTest(FunctionalTest):
|
||||
By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
|
||||
)
|
||||
)
|
||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 0)
|
||||
# Row preserved as quota tracker; hand wiped.
|
||||
rows = MySeaDraw.objects.filter(user=self.gamer)
|
||||
self.assertEqual(rows.count(), 1)
|
||||
self.assertEqual(rows.first().hand, [])
|
||||
# Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec.
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")
|
||||
self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
pointer-events: none;
|
||||
font-size: 1.2rem;
|
||||
padding-bottom: 0.1rem;
|
||||
color: rgba(var(--secUser), 0.25) !important;
|
||||
color: transparent !important; // hide native text
|
||||
background-color: rgba(var(--priUser), 1) !important;
|
||||
border-color: rgba(var(--secUser), 0.25) !important;
|
||||
box-shadow:
|
||||
@@ -523,6 +523,28 @@
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
|
||||
;
|
||||
position: relative;
|
||||
|
||||
// Universal × overlay — any `.btn-disabled` button reads as ×
|
||||
// regardless of its native inner text/icons (DEL → ×, FLIP → ×,
|
||||
// LOCK HAND → ×, etc.). Templates that already render `×`
|
||||
// explicitly (e.g. don/doff toggle pairs) just have their inner
|
||||
// × hidden by `visibility: hidden` on children; the pseudo's
|
||||
// glyph is the only one visible — no double-× regression. User
|
||||
// spec 2026-05-20.
|
||||
> * { visibility: hidden; }
|
||||
|
||||
&::before {
|
||||
content: "\00d7";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--secUser), 0.5);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
|
||||
@@ -915,7 +915,8 @@ html:has(.sig-backdrop) {
|
||||
// data-polarity lives on the page wrapper (not on .my-sign-stage) so descendant
|
||||
// `.sig-card` (in the grid, sibling to the stage) inherits the rules.
|
||||
.sig-overlay[data-polarity="levity"],
|
||||
.my-sign-page[data-polarity="levity"] {
|
||||
.my-sign-page[data-polarity="levity"],
|
||||
.my-sea-page[data-polarity="levity"] {
|
||||
// Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name
|
||||
// and .fan-card-corner that out-specifc the parent color, so re-target them here.
|
||||
.sig-card {
|
||||
@@ -969,7 +970,8 @@ html:has(.sig-backdrop) {
|
||||
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||
}
|
||||
.sig-overlay[data-polarity="gravity"],
|
||||
.my-sign-page[data-polarity="gravity"] {
|
||||
.my-sign-page[data-polarity="gravity"],
|
||||
.my-sea-page[data-polarity="gravity"] {
|
||||
// Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
|
||||
.sig-stat-block {
|
||||
background: rgba(var(--secUser), 0.75);
|
||||
|
||||
@@ -276,6 +276,15 @@ body.page-gameboard {
|
||||
background: rgba(var(--duoUser), 1);
|
||||
}
|
||||
|
||||
// Landing phase bg — explicit `--priUser` revert per user spec
|
||||
// (2026-05-20). The hex INTERIOR is `--duoUser` (set on `.table-hex`
|
||||
// in _room.scss); the aperture AROUND the hex should be the default
|
||||
// body color. Defensive override so any bf-cache / stale-CSS state
|
||||
// can't leak the picker-phase green bg onto a landing render.
|
||||
.my-sea-page[data-phase="landing"] {
|
||||
background: rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
.my-sea-picker {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||
|
||||
{% block content %}
|
||||
<div class="my-sea-page" data-phase="{% if active_draw %}picker{% else %}landing{% endif %}">
|
||||
<div class="my-sea-page"
|
||||
data-phase="{% if show_picker %}picker{% else %}landing{% endif %}"
|
||||
data-polarity="{% if significator_reversed %}gravity{% else %}levity{% endif %}">
|
||||
{% if not user_has_sig %}
|
||||
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
||||
{# significator — render a Look!-formatted Brief-style line w. #}
|
||||
@@ -24,15 +26,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if not active_draw %}
|
||||
{% if not show_picker %}
|
||||
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
||||
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
|
||||
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
|
||||
{# friend-invite feature per the My Sea roadmap architectural #}
|
||||
{# anchor "Six chairs retained even in solo". DRAW SEA btn #}
|
||||
{# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #}
|
||||
{# an active draw exists (iter 4b) — the picker phase is the #}
|
||||
{# landing state once the user has spent their free quota. #}
|
||||
{# anchor "Six chairs retained even in solo". #}
|
||||
{# #}
|
||||
{# Iter 4c — landing primary btn is: #}
|
||||
{# • FREE DRAW (`#id_draw_sea_btn`) when the user has no #}
|
||||
{# active quota row (fresh user OR >24h since last draw); #}
|
||||
{# • GATE VIEW (`#id_my_sea_gate_view_btn`) when the user's #}
|
||||
{# quota row exists but the hand is empty (post-DEL). The #}
|
||||
{# daily free draw is spent; further draws require a token #}
|
||||
{# deposit via the Sprint-6 gatekeeper (currently 404). #}
|
||||
<div class="my-sea-landing">
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
@@ -40,13 +47,14 @@
|
||||
<div class="table-hex-border">
|
||||
<div class="table-hex">
|
||||
<div class="table-center">
|
||||
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #}
|
||||
{# Future sprint will conditionally swap this for a DRAW SEA #}
|
||||
{# .btn-primary that calls the gatekeeper partial once the #}
|
||||
{# free daily has been used; until then the btn renders FREE #}
|
||||
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #}
|
||||
{# entry point) so the swap is label-only when iter 6+ lands. #}
|
||||
{% if quota_spent %}
|
||||
<button id="id_my_sea_gate_view_btn"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
data-gate-url="{% url 'my_sea_gate' %}">GATE<br>VIEW</button>
|
||||
{% else %}
|
||||
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +94,7 @@
|
||||
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
|
||||
{# openStage(), which fills the slot AND opens the portaled #}
|
||||
{# stage modal w. SPIN / FYI controls. #}
|
||||
<div class="my-sea-picker{% if active_draw %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not active_draw %} style="display:none"{% endif %}>
|
||||
<div class="my-sea-picker{% if hand_complete %} my-sea-picker--locked{% endif %}" id="id_sea_overlay"{% if not show_picker %} style="display:none"{% endif %}>
|
||||
<div class="sea-cards-col">
|
||||
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
|
||||
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
|
||||
@@ -190,10 +198,26 @@
|
||||
</div>
|
||||
|
||||
<div class="sea-form-actions">
|
||||
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
|
||||
LOCK HAND
|
||||
</button>
|
||||
<button type="button" id="id_sea_del" class="btn btn-danger">
|
||||
{# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) #}
|
||||
{# / GATE VIEW (hand complete). Same button slot; JS #}
|
||||
{# transitions label + behavior + `data-state` when #}
|
||||
{# the final card lands. AUTO DRAW opens a guard portal #}
|
||||
{# ("Auto deal cards?") + POSTs the remaining hand in #}
|
||||
{# one shot so a navigate-away mid-animation still #}
|
||||
{# persists. GATE VIEW navigates to the Sprint-6 gate- #}
|
||||
{# keeper (currently a 404 stub). #}
|
||||
<button type="button"
|
||||
id="id_sea_action_btn"
|
||||
class="btn btn-primary"
|
||||
data-state="{% if hand_complete %}gate-view{% else %}auto-draw{% endif %}"
|
||||
data-gate-url="{% url 'my_sea_gate' %}">{% if hand_complete %}GATE<br>VIEW{% else %}AUTO<br>DRAW{% endif %}</button>
|
||||
{# DEL starts `.btn-disabled` until the hand is #}
|
||||
{# complete — per Sprint-5-iter-4c spec, the 1/day #}
|
||||
{# quota is committed at first-card-draw + can't be #}
|
||||
{# refunded by an early DEL. #}
|
||||
<button type="button"
|
||||
id="id_sea_del"
|
||||
class="btn btn-danger{% if not hand_complete %} btn-disabled{% endif %}">
|
||||
DEL
|
||||
</button>
|
||||
</div>
|
||||
@@ -252,7 +276,7 @@
|
||||
var hidden = document.getElementById('id_sea_spread');
|
||||
var cross = document.querySelector('.my-sea-cross');
|
||||
var picker = document.querySelector('.my-sea-picker');
|
||||
var lockBtn= document.getElementById('id_sea_lock_hand');
|
||||
var actionBtn = document.getElementById('id_sea_action_btn');
|
||||
var delBtn = document.getElementById('id_sea_del');
|
||||
var deckEl = document.getElementById('id_my_sea_deck');
|
||||
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
|
||||
@@ -373,6 +397,10 @@
|
||||
}
|
||||
|
||||
function _resetHand() {
|
||||
// Spread-switch reset only (mid-draw guard inside sync()).
|
||||
// Iter 4c removed the explicit DEL-resets-hand pathway:
|
||||
// pre-completion DEL is `.btn-disabled`; post-completion
|
||||
// DEL routes to the guard portal → server-side delete.
|
||||
_filled = 0;
|
||||
_hideOk();
|
||||
_unlockSpread();
|
||||
@@ -384,37 +412,35 @@
|
||||
'.sea-pos-cover, .sea-pos-cross'
|
||||
).forEach(_emptySlot);
|
||||
_reshuffleDeck();
|
||||
if (lockBtn) lockBtn.disabled = true;
|
||||
// Wipe SeaDeal's stage state too — closes a lingering
|
||||
// modal + clears its `_seaHand` map so previously-
|
||||
// drawn cards can't reopen via slot-tap focus.
|
||||
if (window.SeaDeal && window.SeaDeal.resetHand) {
|
||||
SeaDeal.resetHand();
|
||||
}
|
||||
}
|
||||
|
||||
function _setLocked(on) {
|
||||
function _setComplete(on) {
|
||||
// Iter 4c — single-call state transition for "hand
|
||||
// complete": DEL un-disables, action btn becomes GATE
|
||||
// VIEW, FLIP buttons on the deck stacks render as
|
||||
// disabled when shown. The deck STACKS themselves stay
|
||||
// click-responsive (so the user can see the disabled
|
||||
// FLIP feedback) — `_locked` gates the actual draw.
|
||||
_locked = on;
|
||||
picker.classList.toggle('my-sea-picker--locked', on);
|
||||
// Decks + LOCK HAND go disabled; DEL stays interactive
|
||||
// — it's the user's escape from the locked state
|
||||
// (opens the guard portal in iter 4b, resets the hand
|
||||
// in iter 4a). Without this exemption the SCSS rule
|
||||
// `.btn-disabled { pointer-events: none }` would block
|
||||
// every Selenium / user click on DEL post-LOCK.
|
||||
[picker.querySelector('.sea-deck-stack--levity'),
|
||||
picker.querySelector('.sea-deck-stack--gravity'),
|
||||
lockBtn].forEach(function (el) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('btn-disabled', on);
|
||||
});
|
||||
if (delBtn) delBtn.classList.toggle('btn-disabled', !on);
|
||||
if (actionBtn) {
|
||||
actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw';
|
||||
actionBtn.innerHTML = on ? 'GATE<br>VIEW' : 'AUTO<br>DRAW';
|
||||
}
|
||||
_hideOk();
|
||||
}
|
||||
|
||||
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
|
||||
// Iter 4c — stacks remain click-responsive even after hand
|
||||
// is complete (so the user sees the disabled-FLIP feedback,
|
||||
// signalling "no more draws allowed"). Each card placement
|
||||
// POSTs the current hand to /lock for server upsert.
|
||||
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
|
||||
stack.addEventListener('click', function (e) {
|
||||
if (_locked) return;
|
||||
e.stopPropagation();
|
||||
if (_activeStack === stack) _hideOk();
|
||||
else _showOk(stack);
|
||||
@@ -423,68 +449,149 @@
|
||||
if (ok) {
|
||||
ok.style.display = 'none';
|
||||
ok.addEventListener('click', function (e) {
|
||||
if (_locked) return;
|
||||
e.stopPropagation();
|
||||
// Hand complete → FLIP is disabled. No draw.
|
||||
if (_locked) { _hideOk(); return; }
|
||||
var isLevity = stack.classList.contains('sea-deck-stack--levity');
|
||||
var pile = isLevity ? _levityPile : _gravityPile;
|
||||
var card = pile.length ? pile.shift() : null;
|
||||
var order = _currentOrder();
|
||||
var posName = order[_filled];
|
||||
if (card && posName) {
|
||||
// Delegate to SeaDeal — it `_fillSlot`s
|
||||
// (sets corner-rank + suit-icon + polarity
|
||||
// class on the slot at opacity 0) AND opens
|
||||
// the portaled stage modal w. SPIN / FYI.
|
||||
// Click-the-backdrop dismisses + the slot
|
||||
// fades to `.--visible` revealing the
|
||||
// thumbnail.
|
||||
if (window.SeaDeal && window.SeaDeal.openStage) {
|
||||
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
|
||||
} else {
|
||||
// Defensive fallback for environments
|
||||
// where sea.js failed to load (e.g.
|
||||
// collectstatic miss). Render the slot
|
||||
// visibly so the draw isn't lost.
|
||||
_fillSlot(posName, card, isLevity);
|
||||
}
|
||||
_filled++;
|
||||
// First deposit locks the SPREAD combobox —
|
||||
// switching mid-draw would scramble the
|
||||
// in-progress hand's position mapping.
|
||||
if (_filled === 1) _lockSpread();
|
||||
if (lockBtn) lockBtn.disabled = (_filled < order.length);
|
||||
// Per-placement upsert — server stays current
|
||||
// so a navigate-away mid-draw still persists
|
||||
// the partial hand. User-spec 2026-05-20.
|
||||
_postLock(_collectHandFromDom());
|
||||
if (_filled >= order.length) {
|
||||
_setComplete(true);
|
||||
}
|
||||
}
|
||||
_hideOk();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function _collectHandFromDom() {
|
||||
// Walks every `.sea-card-slot--filled` and reconstructs
|
||||
// the hand array in DRAW_ORDER. data-card-id, polarity
|
||||
// class, and `.sea-card-slot--reversed` are set at
|
||||
// FLIP-click time so this is always current.
|
||||
var byPos = {};
|
||||
cross.querySelectorAll(
|
||||
'.sea-card-slot.sea-card-slot--filled'
|
||||
).forEach(function (slot) {
|
||||
var cls = slot.className;
|
||||
var pos = slot.dataset.posKey || '';
|
||||
if (!pos) return;
|
||||
byPos[pos] = {
|
||||
position: pos,
|
||||
card_id: parseInt(slot.dataset.cardId, 10),
|
||||
reversed: /sea-card-slot--reversed\b/.test(cls),
|
||||
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
|
||||
};
|
||||
});
|
||||
// Emit in DRAW_ORDER so the server stores cards in
|
||||
// chronological draw order (Sprint 7 applet reads this
|
||||
// sequence left-to-right).
|
||||
var hand = [];
|
||||
_currentOrder().forEach(function (pos) {
|
||||
if (byPos[pos]) hand.push(byPos[pos]);
|
||||
});
|
||||
return hand;
|
||||
}
|
||||
|
||||
function _autoDraw() {
|
||||
// Compute all remaining cards client-side + POST in one
|
||||
// shot (per user spec 2026-05-20: "commit all six draws
|
||||
// in the same POST, just display them in order via js"
|
||||
// — survives navigate-away mid-animation). After server
|
||||
// commit, animate placement sequentially.
|
||||
var order = _currentOrder();
|
||||
var remaining = order.length - _filled;
|
||||
if (remaining <= 0) return;
|
||||
var autoEntries = [];
|
||||
for (var i = 0; i < remaining; i++) {
|
||||
var isLevity = Math.random() < 0.5;
|
||||
var pile = isLevity ? _levityPile : _gravityPile;
|
||||
if (!pile.length) {
|
||||
isLevity = !isLevity;
|
||||
pile = isLevity ? _levityPile : _gravityPile;
|
||||
}
|
||||
if (!pile.length) break;
|
||||
var card = pile.shift();
|
||||
autoEntries.push({
|
||||
card: card,
|
||||
posName: order[_filled + i],
|
||||
isLevity: isLevity,
|
||||
});
|
||||
}
|
||||
var fullHand = _collectHandFromDom();
|
||||
autoEntries.forEach(function (e) {
|
||||
fullHand.push({
|
||||
position: e.posName,
|
||||
card_id: e.card.id,
|
||||
reversed: !!e.card.reversed,
|
||||
polarity: e.isLevity ? 'levity' : 'gravity',
|
||||
});
|
||||
});
|
||||
_postLock(fullHand).then(function (body) {
|
||||
if (!body || !body.ok) return;
|
||||
// POST succeeded → animate placement client-side.
|
||||
// Even if the user navigates away now, the server
|
||||
// already has the full hand — reload restores the
|
||||
// complete picker state.
|
||||
if (autoEntries.length === 0) {
|
||||
_setComplete(true);
|
||||
return;
|
||||
}
|
||||
if (_filled === 0) _lockSpread();
|
||||
var idx = 0;
|
||||
function placeNext() {
|
||||
if (idx >= autoEntries.length) {
|
||||
_setComplete(true);
|
||||
return;
|
||||
}
|
||||
var e = autoEntries[idx++];
|
||||
var stack = picker.querySelector(
|
||||
'.sea-deck-stack--' + (e.isLevity ? 'levity' : 'gravity')
|
||||
);
|
||||
if (stack) _showOk(stack);
|
||||
setTimeout(function () {
|
||||
_fillSlot(e.posName, e.card, e.isLevity);
|
||||
cross.querySelector('.sea-pos-' + e.posName + ' .sea-card-slot')
|
||||
.classList.add('sea-card-slot--visible');
|
||||
_filled++;
|
||||
_hideOk();
|
||||
setTimeout(placeNext, 250);
|
||||
}, 350);
|
||||
}
|
||||
placeNext();
|
||||
});
|
||||
}
|
||||
|
||||
// Click elsewhere inside the picker dismisses the FLIP btn.
|
||||
picker.addEventListener('click', _hideOk);
|
||||
|
||||
// ── DEL semantics differ by lock state ──────────────────
|
||||
// Pre-lock: DEL resets the in-progress hand client-side
|
||||
// (iter 4a behaviour — no server round-trip).
|
||||
// Post-lock: DEL invokes the shared `#id_guard_portal`
|
||||
// from base.html via `window.showGuard`, w. a
|
||||
// "DEL" YES-label override (the room's gear-
|
||||
// menu DEL flow uses the same portal). On YES
|
||||
// we POST to /gameboard/my-sea/delete then
|
||||
// navigate back to the page (server returns
|
||||
// 204; we redirect manually to land on the
|
||||
// FREE DRAW landing).
|
||||
// ── DEL: post-completion only (server-rendered `.btn-
|
||||
// disabled` pre-completion gates pre-hand DEL attempts).
|
||||
// Opens the shared `#id_guard_portal` from base.html;
|
||||
// CONFIRM POSTs to /gameboard/my-sea/delete which clears
|
||||
// the hand but preserves the row (quota stays committed
|
||||
// for the 24h window). Server returns 204; we reload to
|
||||
// land on the GATE VIEW landing (FREE DRAW is gone until
|
||||
// the row expires).
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!_locked) {
|
||||
_resetHand();
|
||||
return;
|
||||
}
|
||||
if (delBtn.classList.contains('btn-disabled')) return;
|
||||
if (!window.showGuard) return;
|
||||
// Trigger btn (DEL, `.btn-danger`) opens the shared
|
||||
// guard portal; the portal's confirm button is the
|
||||
// standard `.btn-confirm` "OK" + `.btn-cancel` "NVM"
|
||||
// pair — matches the room gear-menu DEL flow exactly.
|
||||
window.showGuard(
|
||||
delBtn,
|
||||
'Are you sure?',
|
||||
@@ -500,29 +607,28 @@
|
||||
);
|
||||
});
|
||||
}
|
||||
if (lockBtn) {
|
||||
lockBtn.addEventListener('click', function () {
|
||||
if (lockBtn.disabled || _locked) return;
|
||||
// Collect the in-progress hand for the POST. Slot
|
||||
// class includes `--levity` / `--gravity`; reversed
|
||||
// is on `.sea-card-slot--reversed`. Position comes
|
||||
// from `data-pos-key`. Card id from `data-card-id`.
|
||||
var hand = [];
|
||||
cross.querySelectorAll(
|
||||
'.sea-card-slot.sea-card-slot--filled'
|
||||
).forEach(function (slot) {
|
||||
var cls = slot.className;
|
||||
hand.push({
|
||||
position: slot.dataset.posKey || '',
|
||||
card_id: parseInt(slot.dataset.cardId, 10),
|
||||
reversed: /sea-card-slot--reversed\b/.test(cls),
|
||||
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
|
||||
});
|
||||
});
|
||||
var order = _currentOrder();
|
||||
if (hand.length < order.length) return;
|
||||
_setLocked(true);
|
||||
_postLock(hand);
|
||||
|
||||
// ── Action btn: AUTO DRAW (pre-complete) ↔ GATE VIEW
|
||||
// (post-complete). Same DOM node, label + behavior keyed
|
||||
// on `data-state`. AUTO DRAW opens a guard portal "Auto
|
||||
// deal cards?"; on OK, fills remaining slots client-side
|
||||
// + commits to server in one POST. GATE VIEW navigates
|
||||
// to the Sprint-6 gatekeeper (currently 404 stub).
|
||||
if (actionBtn) {
|
||||
actionBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
var state = actionBtn.dataset.state;
|
||||
if (state === 'gate-view') {
|
||||
window.location.href = actionBtn.dataset.gateUrl || '#';
|
||||
return;
|
||||
}
|
||||
// AUTO DRAW
|
||||
if (!window.showGuard) return;
|
||||
window.showGuard(
|
||||
actionBtn,
|
||||
'Auto deal cards?',
|
||||
_autoDraw
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,7 +693,13 @@
|
||||
}
|
||||
};
|
||||
function _postLock(hand) {
|
||||
fetch('{% url "my_sea_lock" %}', {
|
||||
// Returns the parsed JSON body promise so callers (e.g.
|
||||
// _autoDraw) can chain animation onto server commit.
|
||||
// Surfaces the Brief banner only on the *first* commit
|
||||
// of this session (quota commit moment) — dedupe via
|
||||
// `.my-sea-locked-banner` presence in DOM, so per-
|
||||
// placement POSTs don't spam multiple banners.
|
||||
return fetch('{% url "my_sea_lock" %}', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
@@ -601,9 +713,11 @@
|
||||
}).then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
}).then(function (body) {
|
||||
if (body && body.next_free_draw_at) {
|
||||
if (body && body.next_free_draw_at
|
||||
&& !document.querySelector('.my-sea-locked-banner')) {
|
||||
window._showFreeDrawLockedBrief(body.next_free_draw_at);
|
||||
}
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -623,20 +737,24 @@
|
||||
}
|
||||
hidden.addEventListener('change', sync);
|
||||
|
||||
// Initial state — labels already server-rendered for the
|
||||
// default spread. If the picker was server-rendered w. a
|
||||
// locked saved hand (iter 4b's active_draw branch), the
|
||||
// .my-sea-picker--locked class is already on the page —
|
||||
// mirror it in JS state so further interactions stay
|
||||
// disabled + DEL routes to the guard portal.
|
||||
_filled = 0;
|
||||
var _preLocked = picker.classList.contains('my-sea-picker--locked');
|
||||
if (_preLocked) {
|
||||
_filled = _currentOrder().length;
|
||||
_setLocked(true);
|
||||
// Initial state — iter-4c restoration of partial / complete
|
||||
// saved-hand sessions. Server pre-renders the slots from
|
||||
// saved_by_position; count the filled slots to seed _filled.
|
||||
// If hand is complete (server-rendered `.my-sea-picker--
|
||||
// locked`), call `_setComplete(true)` to align JS state
|
||||
// (decks click-responsive but FLIP arrives disabled; DEL
|
||||
// enabled; action btn = GATE VIEW). Mid-draw saved hands
|
||||
// just need the count + spread lock (the user resumes
|
||||
// drawing from where they left off).
|
||||
_filled = cross.querySelectorAll(
|
||||
'.sea-card-slot.sea-card-slot--filled'
|
||||
).length;
|
||||
if (_filled >= _currentOrder().length) {
|
||||
_setComplete(true);
|
||||
_lockSpread();
|
||||
} else if (_filled > 0) {
|
||||
_lockSpread();
|
||||
}
|
||||
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
|
||||
|
||||
// Belt-and-braces autofill defense (paired w. autocomplete=
|
||||
// off on the hidden input above). Firefox occasionally
|
||||
@@ -709,6 +827,15 @@
|
||||
}, SEAT_ANIM_MS);
|
||||
});
|
||||
}
|
||||
// Iter 4c — landing GATE VIEW (replaces FREE DRAW when the
|
||||
// user's quota row exists w. empty hand, i.e. post-DEL).
|
||||
// Navigates to the Sprint-6 gatekeeper (currently 404 stub).
|
||||
var gateLandingBtn = document.getElementById('id_my_sea_gate_view_btn');
|
||||
if (gateLandingBtn) {
|
||||
gateLandingBtn.addEventListener('click', function () {
|
||||
window.location.href = gateLandingBtn.dataset.gateUrl || '#';
|
||||
});
|
||||
}
|
||||
// Mirror my-sign's scaleTable() init timing fix — the
|
||||
// .my-sea-page hasn't flushed its flex sizing on
|
||||
// DOMContentLoaded, so the hex stays unscaled until we
|
||||
|
||||
Reference in New Issue
Block a user