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
|
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):
|
class MySeaDraw(models.Model):
|
||||||
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
|
"""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
|
Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
|
||||||
persistence of the iter-4a client-side draw mechanic.
|
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
|
||||||
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
|
committed at first-card moment + survives a DEL (DEL clears the
|
||||||
(24h, irrespective of spread type). Subsequent draws within the
|
`hand` JSON but preserves the row — so `created_at` keeps running
|
||||||
window are intended to be gated behind a token deposit at the My
|
the 24h clock + landing renders GATE VIEW instead of FREE DRAW
|
||||||
Sea gatekeeper, which Sprint 6 will build.
|
until the row expires).
|
||||||
|
|
||||||
`hand` is an ordered list of position-dicts in draw order — Sprint 7's
|
`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:
|
applet renders them left-to-right in that order. Each entry shape:
|
||||||
|
|
||||||
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
|
{"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
|
`significator_id` + `significator_reversed` snapshot the user's sig
|
||||||
at lock time so a subsequent `User.significator = None` (via my-sign
|
at first-card-draw time so a subsequent `User.significator = None`
|
||||||
DEL) doesn't invalidate the saved draw — per user spec, preserve the
|
(via my-sign DEL) doesn't invalidate the saved draw.
|
||||||
old sig; any future draw uses whatever sig is current at that time.
|
|
||||||
"""
|
"""
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
|
|||||||
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
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):
|
def active_draw_for(user):
|
||||||
"""Return the user's most-recent draw within the quota window, or
|
"""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
|
None. Single source of truth for "does the user have an active draw"
|
||||||
and for gating LOCK HAND POSTs.
|
(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
|
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
|
||||||
caller keeps the 24h window a single-source-of-truth tied to
|
the DB doesn't accumulate one row per user per day. The user-spec
|
||||||
FREE_DRAW_COOLDOWN_HOURS."""
|
'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)
|
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
|
||||||
|
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
|
||||||
return MySeaDraw.objects.filter(
|
return MySeaDraw.objects.filter(
|
||||||
user=user, created_at__gte=cutoff,
|
user=user, created_at__gte=cutoff,
|
||||||
).order_by("-created_at").first()
|
).order_by("-created_at").first()
|
||||||
|
|||||||
@@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase):
|
|||||||
self.assertIn('data-position="loom"></span>', html)
|
self.assertIn('data-position="loom"></span>', html)
|
||||||
self.assertIn('data-position="cross"></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"))
|
response = self.client.get(reverse("my_sea"))
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertIn("sea-deck-stack--gravity", html)
|
self.assertIn("sea-deck-stack--gravity", html)
|
||||||
self.assertIn("sea-deck-stack--levity", 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('id="id_sea_del"', html)
|
||||||
self.assertIn("sea-reversal-hint", html)
|
self.assertIn("sea-reversal-hint", html)
|
||||||
self.assertIn("25% reversals", html)
|
self.assertIn("25% reversals", html)
|
||||||
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
|
|||||||
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
parsed = datetime.fromisoformat(body["next_free_draw_at"])
|
||||||
self.assertIsNotNone(parsed)
|
self.assertIsNotNone(parsed)
|
||||||
|
|
||||||
def test_lock_post_within_quota_window_returns_409(self):
|
def test_lock_post_within_quota_upserts_same_row(self):
|
||||||
# Second lock within 24h: the existing draw already occupies the
|
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
|
||||||
# quota; the server rejects rather than overwriting.
|
# 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
|
import json
|
||||||
from apps.gameboard.models import MySeaDraw
|
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.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",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
response = self.client.post(
|
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",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 409)
|
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):
|
def test_lock_post_empty_hand_returns_400(self):
|
||||||
import json
|
import json
|
||||||
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class MySeaDeleteDrawViewTest(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):
|
def setUp(self):
|
||||||
from apps.epic.models import personal_sig_cards, TarotCard
|
from apps.epic.models import personal_sig_cards, TarotCard
|
||||||
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
|
|||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, 405)
|
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
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
original_created_at = self.draw.created_at
|
||||||
response = self.client.post(self.url)
|
response = self.client.post(self.url)
|
||||||
self.assertIn(response.status_code, (200, 204, 302))
|
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):
|
def test_delete_post_scoped_to_user_does_not_touch_others(self):
|
||||||
from apps.gameboard.models import MySeaDraw
|
from apps.gameboard.models import MySeaDraw
|
||||||
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
|
|||||||
hand=self.draw.hand, significator_id=self.target.id,
|
hand=self.draw.hand, significator_id=self.target.id,
|
||||||
)
|
)
|
||||||
self.client.post(self.url)
|
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):
|
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.
|
# 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)
|
self.client.post(self.url)
|
||||||
response = self.client.post(self.url)
|
response = self.client.post(self.url)
|
||||||
self.assertIn(response.status_code, (200, 204, 302))
|
self.assertIn(response.status_code, (200, 204, 302))
|
||||||
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
|
|||||||
response = self.client.get(reverse("my_sea"))
|
response = self.client.get(reverse("my_sea"))
|
||||||
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
self.assertNotContains(response, 'id="id_draw_sea_btn"')
|
||||||
self.assertNotContains(response, 'data-phase="landing"')
|
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/', views.my_sea, name='my_sea'),
|
||||||
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
|
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/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 django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
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):
|
def _annotate_deck_in_use(decks, user):
|
||||||
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
|
|||||||
def my_sea(request):
|
def my_sea(request):
|
||||||
"""Shell view for the My Sea standalone page.
|
"""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
|
1. No sig → Look!-formatted sign-gate (Sprint 4b).
|
||||||
w. saved hand + Brief banner + DEL guard portal. The draw's sig
|
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
|
||||||
snapshot is rendered (NOT user.significator) so a cleared sig
|
w. saved hand state. The DEL btn is server-rendered `.btn-
|
||||||
elsewhere doesn't invalidate the saved draw.
|
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
|
||||||
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
|
completion. The draw's sig snapshot is rendered (NOT user.
|
||||||
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
|
significator) so a cleared sig elsewhere doesn't invalidate the
|
||||||
click swaps data-phase to picker for a fresh draw.
|
saved draw.
|
||||||
3a. + no equipped deck → also show backup-deck Brief banner.
|
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)
|
active_draw = active_draw_for(request.user)
|
||||||
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
|
||||||
@@ -194,10 +202,22 @@ def my_sea(request):
|
|||||||
default_spread = active_draw.spread
|
default_spread = active_draw.spread
|
||||||
saved_hand = active_draw.hand
|
saved_hand = active_draw.hand
|
||||||
next_free_draw_at = active_draw.next_free_draw_at
|
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:
|
else:
|
||||||
default_spread = "situation-action-outcome"
|
default_spread = "situation-action-outcome"
|
||||||
saved_hand = []
|
saved_hand = []
|
||||||
next_free_draw_at = None
|
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
|
# Per-position lookup for the template — keyed by the position slug
|
||||||
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
|
||||||
@@ -234,11 +254,14 @@ def my_sea(request):
|
|||||||
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
|
||||||
if user_has_sig else {"levity": [], "gravity": []}
|
if user_has_sig else {"levity": [], "gravity": []}
|
||||||
),
|
),
|
||||||
# Iter 4b
|
# Iter 4b / 4c
|
||||||
"active_draw": active_draw,
|
"active_draw": active_draw,
|
||||||
"saved_hand": saved_hand,
|
"saved_hand": saved_hand,
|
||||||
"saved_by_position": saved_by_position,
|
"saved_by_position": saved_by_position,
|
||||||
"next_free_draw_at": next_free_draw_at,
|
"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",
|
"page_class": "page-gameboard page-my-sea",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_lock(request):
|
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,
|
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
|
||||||
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
|
polarity}, ...]}` — `hand` is the current FULL state (partial OK
|
||||||
400 for malformed payload, 409 if the user is still within the free-
|
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
|
||||||
draw cooldown window (existing active draw)."""
|
|
||||||
|
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:
|
try:
|
||||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -272,10 +304,26 @@ def my_sea_lock(request):
|
|||||||
hand = payload.get("hand")
|
hand = payload.get("hand")
|
||||||
if not spread or not isinstance(hand, list) or not hand:
|
if not spread or not isinstance(hand, list) or not hand:
|
||||||
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
|
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:
|
existing = active_draw_for(request.user)
|
||||||
return JsonResponse({"error": "quota_active"}, status=409)
|
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
|
sig_id = request.user.significator_id
|
||||||
if sig_id is None:
|
if sig_id is None:
|
||||||
return JsonResponse({"error": "no_significator"}, status=400)
|
return JsonResponse({"error": "no_significator"}, status=400)
|
||||||
@@ -290,18 +338,35 @@ def my_sea_lock(request):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
|
||||||
|
"hand_complete": draw.is_hand_complete,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_delete(request):
|
def my_sea_delete(request):
|
||||||
"""Delete the user's active draw — invoked by the DEL guard portal's
|
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
|
||||||
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
|
so the 24h quota window keeps running. Per user spec (2026-05-20):
|
||||||
MySeaDraw.objects.filter(user=request.user).delete()
|
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)
|
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):
|
def _my_sea_deck_data(user, exclude_id=None):
|
||||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||||
picker's card-draw mechanic. Card payload shape is whatever
|
picker's card-draw mechanic. Card payload shape is whatever
|
||||||
|
|||||||
@@ -799,100 +799,78 @@ class MySeaCardDrawTest(FunctionalTest):
|
|||||||
|
|
||||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_lock_hand_enables_when_sao_hand_is_complete(self):
|
def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
|
||||||
"""LOCK HAND starts disabled; flips to enabled once all 3 SAO
|
"""Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
|
||||||
positions are drawn (hand-size = 3 for any three-card spread)."""
|
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()
|
picker = self._enter_picker_phase()
|
||||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
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")
|
||||||
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")
|
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(
|
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 ───────────────────────────────────────────────────────────────
|
# ── Test 6 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_del_click_resets_hand_and_disables_lock_hand(self):
|
def test_del_btn_is_disabled_until_hand_complete(self):
|
||||||
"""DEL fully resets — every filled slot returns to `--empty`,
|
"""Iter-4c — DEL btn renders `.btn-disabled` server-side until
|
||||||
labels re-render, _filled counter zeros, LOCK HAND disables."""
|
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()
|
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, "levity")
|
||||||
self._draw_one(picker, "gravity")
|
self._draw_one(picker, "gravity")
|
||||||
self.assertEqual(
|
# Hand complete — DEL un-disables (clicking now opens guard portal).
|
||||||
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()
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(
|
lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
|
||||||
len(picker.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".sea-card-slot.sea-card-slot--filled"
|
|
||||||
)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
|
||||||
self.assertEqual(lock.get_attribute("disabled"), "true")
|
|
||||||
|
|
||||||
# ── Test 7 ───────────────────────────────────────────────────────────────
|
# ── Test 7 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_lock_hand_click_disables_further_interaction(self):
|
def test_hand_completion_locks_picker_state(self):
|
||||||
"""After LOCK HAND fires, deck swatches + DEL btn + LOCK HAND
|
"""Iter-4c — when the final card lands (manual or AUTO DRAW),
|
||||||
itself all carry the `.btn-disabled` class so the hand can't
|
the picker gains `.my-sea-picker--locked`; further deck-stack
|
||||||
be mutated further. Persistence (POST to a server endpoint)
|
clicks still SHOW the FLIP btn (so the user can see why no
|
||||||
defers to iter 4b — this test pins only the visual lock."""
|
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()
|
picker = self._enter_picker_phase()
|
||||||
self._draw_one(picker, "levity")
|
self._draw_one(picker, "levity")
|
||||||
self._draw_one(picker, "levity")
|
self._draw_one(picker, "levity")
|
||||||
self._draw_one(picker, "gravity")
|
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(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(
|
lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
|
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 ───────────────────────────────────────────────────────────────
|
# ── Test 8 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_first_draw_locks_spread_combobox(self):
|
def test_first_draw_locks_spread_combobox(self):
|
||||||
"""Per the iter-4a follow-up spec lock (2026-05-19): once the
|
"""Iter-4c — once the first card lands, the SPREAD combobox
|
||||||
first card lands, the SPREAD combobox carries `.sea-select--
|
carries `.sea-select--locked` for the rest of the quota window.
|
||||||
locked` so mid-draw spread switching is prevented (it would
|
The spread is committed at first-card moment (server-side too:
|
||||||
scramble the position→card mapping). DEL releases the lock.
|
any later POST w. a different spread → 409); no client-side
|
||||||
|
unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
|
||||||
Was previously `test_switching_spread_resets_in_progress_hand`
|
`.btn-disabled` pre-completion → no reset pathway.)"""
|
||||||
— that test's premise (mid-draw spread switch resets hand) is
|
|
||||||
obsolete now that switching is blocked outright."""
|
|
||||||
picker = self._enter_picker_phase()
|
picker = self._enter_picker_phase()
|
||||||
self._draw_one(picker, "levity")
|
self._draw_one(picker, "levity")
|
||||||
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
|
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 ───────────────────────────────────────────────────────────────
|
# ── Test 4 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -910,14 +888,18 @@ class MySeaCardDrawTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.assertIn("GRAVITY", names)
|
self.assertIn("GRAVITY", names)
|
||||||
self.assertIn("LEVITY", names)
|
self.assertIn("LEVITY", names)
|
||||||
# LOCK HAND + DEL
|
# Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
|
||||||
lock = picker.find_element(By.CSS_SELECTOR, "#id_sea_lock_hand")
|
action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
|
||||||
self.assertIn("LOCK", lock.text.upper())
|
self.assertIn("AUTO", action_btn.text.upper())
|
||||||
self.assertIn("HAND", lock.text.upper())
|
self.assertIn("btn-primary", action_btn.get_attribute("class"))
|
||||||
self.assertIn("btn-primary", lock.get_attribute("class"))
|
self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
|
||||||
delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
|
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-danger", delbtn.get_attribute("class"))
|
||||||
|
self.assertIn("btn-disabled", delbtn.get_attribute("class"))
|
||||||
# Reversal % caption — default 25
|
# Reversal % caption — default 25
|
||||||
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
|
||||||
self.assertIn("25", hint.text)
|
self.assertIn("25", hint.text)
|
||||||
@@ -1157,11 +1139,13 @@ class MySeaLockHandTest(FunctionalTest):
|
|||||||
|
|
||||||
# ── Test 5 ───────────────────────────────────────────────────────────────
|
# ── Test 5 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_del_confirm_clears_saved_draw_and_returns_to_landing(self):
|
def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
|
||||||
"""Clicking the portal's OK (`.guard-yes`) POSTs to the delete
|
"""Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
|
||||||
endpoint → server wipes the MySeaDraw row → reload lands on the
|
POSTs to the delete endpoint → server CLEARS the hand JSON but
|
||||||
FREE DRAW landing again (no saved hand, no Brief banner, FREE
|
preserves the MySeaDraw row (quota tracker stays running for the
|
||||||
DRAW btn present)."""
|
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
|
from apps.gameboard.models import MySeaDraw
|
||||||
self._save_draw_for_user()
|
self._save_draw_for_user()
|
||||||
self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
|
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']"
|
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;
|
pointer-events: none;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
padding-bottom: 0.1rem;
|
padding-bottom: 0.1rem;
|
||||||
color: rgba(var(--secUser), 0.25) !important;
|
color: transparent !important; // hide native text
|
||||||
background-color: rgba(var(--priUser), 1) !important;
|
background-color: rgba(var(--priUser), 1) !important;
|
||||||
border-color: rgba(var(--secUser), 0.25) !important;
|
border-color: rgba(var(--secUser), 0.25) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -523,6 +523,28 @@
|
|||||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
0.25rem 0.25rem 0.25rem rgba(var(--secUser), 0.12)
|
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 {
|
&:hover {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
|
|||||||
@@ -915,7 +915,8 @@ html:has(.sig-backdrop) {
|
|||||||
// data-polarity lives on the page wrapper (not on .my-sign-stage) so descendant
|
// 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-card` (in the grid, sibling to the stage) inherits the rules.
|
||||||
.sig-overlay[data-polarity="levity"],
|
.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
|
// 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.
|
// and .fan-card-corner that out-specifc the parent color, so re-target them here.
|
||||||
.sig-card {
|
.sig-card {
|
||||||
@@ -969,7 +970,8 @@ html:has(.sig-backdrop) {
|
|||||||
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||||
}
|
}
|
||||||
.sig-overlay[data-polarity="gravity"],
|
.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
|
// Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
|
||||||
.sig-stat-block {
|
.sig-stat-block {
|
||||||
background: rgba(var(--secUser), 0.75);
|
background: rgba(var(--secUser), 0.75);
|
||||||
|
|||||||
@@ -276,6 +276,15 @@ body.page-gameboard {
|
|||||||
background: rgba(var(--duoUser), 1);
|
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 {
|
.my-sea-picker {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 %}
|
{% if not user_has_sig %}
|
||||||
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
{# Sprint 4b sign-gate. The draw UX is gated behind a saved #}
|
||||||
{# significator — render a Look!-formatted Brief-style line w. #}
|
{# significator — render a Look!-formatted Brief-style line w. #}
|
||||||
@@ -24,15 +26,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not active_draw %}
|
{% if not show_picker %}
|
||||||
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
{# Sprint 5 iter 1 — DRAW SEA landing UX. DRY table hex from #}
|
||||||
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
|
{# the room shell (.room-shell > .room-table > … > .table-hex) #}
|
||||||
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
|
{# w. 6 chair seats labeled 1C-6C as placeholders for the #}
|
||||||
{# friend-invite feature per the My Sea roadmap architectural #}
|
{# friend-invite feature per the My Sea roadmap architectural #}
|
||||||
{# anchor "Six chairs retained even in solo". DRAW SEA btn #}
|
{# anchor "Six chairs retained even in solo". #}
|
||||||
{# mirrors SCAN SIGN on /billboard/my-sign/. Suppressed when #}
|
{# #}
|
||||||
{# an active draw exists (iter 4b) — the picker phase is the #}
|
{# Iter 4c — landing primary btn is: #}
|
||||||
{# landing state once the user has spent their free quota. #}
|
{# • 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="my-sea-landing">
|
||||||
<div class="room-shell">
|
<div class="room-shell">
|
||||||
<div id="id_game_table" class="room-table">
|
<div id="id_game_table" class="room-table">
|
||||||
@@ -40,13 +47,14 @@
|
|||||||
<div class="table-hex-border">
|
<div class="table-hex-border">
|
||||||
<div class="table-hex">
|
<div class="table-hex">
|
||||||
<div class="table-center">
|
<div class="table-center">
|
||||||
{# Sprint 5 iter 1 — FREE DRAW = the 1/24hr free-quota draw. #}
|
{% if quota_spent %}
|
||||||
{# Future sprint will conditionally swap this for a DRAW SEA #}
|
<button id="id_my_sea_gate_view_btn"
|
||||||
{# .btn-primary that calls the gatekeeper partial once the #}
|
type="button"
|
||||||
{# free daily has been used; until then the btn renders FREE #}
|
class="btn btn-primary"
|
||||||
{# DRAW. ID retained as `id_draw_sea_btn` (intent: the draw #}
|
data-gate-url="{% url 'my_sea_gate' %}">GATE<br>VIEW</button>
|
||||||
{# entry point) so the swap is label-only when iter 6+ lands. #}
|
{% else %}
|
||||||
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
|
<button id="id_draw_sea_btn" type="button" class="btn btn-primary">FREE<br>DRAW</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +94,7 @@
|
|||||||
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
|
{# exist in one DOM). FLIP click delegates to SeaDeal. #}
|
||||||
{# openStage(), which fills the slot AND opens the portaled #}
|
{# openStage(), which fills the slot AND opens the portaled #}
|
||||||
{# stage modal w. SPIN / FYI controls. #}
|
{# 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-cards-col">
|
||||||
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
|
<div class="sea-cross my-sea-cross" data-spread="{{ default_spread }}">
|
||||||
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
|
{# `.sea-pos-label` lives OUTSIDE the slot so SeaDeal._fillSlot's #}
|
||||||
@@ -190,10 +198,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sea-form-actions">
|
<div class="sea-form-actions">
|
||||||
<button type="button" id="id_sea_lock_hand" class="btn btn-primary" disabled>
|
{# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) #}
|
||||||
LOCK HAND
|
{# / GATE VIEW (hand complete). Same button slot; JS #}
|
||||||
</button>
|
{# transitions label + behavior + `data-state` when #}
|
||||||
<button type="button" id="id_sea_del" class="btn btn-danger">
|
{# 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
|
DEL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +276,7 @@
|
|||||||
var hidden = document.getElementById('id_sea_spread');
|
var hidden = document.getElementById('id_sea_spread');
|
||||||
var cross = document.querySelector('.my-sea-cross');
|
var cross = document.querySelector('.my-sea-cross');
|
||||||
var picker = document.querySelector('.my-sea-picker');
|
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 delBtn = document.getElementById('id_sea_del');
|
||||||
var deckEl = document.getElementById('id_my_sea_deck');
|
var deckEl = document.getElementById('id_my_sea_deck');
|
||||||
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
|
var seaSelect = document.querySelector('[data-combobox][data-combobox-target="id_sea_spread"]');
|
||||||
@@ -373,6 +397,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _resetHand() {
|
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;
|
_filled = 0;
|
||||||
_hideOk();
|
_hideOk();
|
||||||
_unlockSpread();
|
_unlockSpread();
|
||||||
@@ -384,37 +412,35 @@
|
|||||||
'.sea-pos-cover, .sea-pos-cross'
|
'.sea-pos-cover, .sea-pos-cross'
|
||||||
).forEach(_emptySlot);
|
).forEach(_emptySlot);
|
||||||
_reshuffleDeck();
|
_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) {
|
if (window.SeaDeal && window.SeaDeal.resetHand) {
|
||||||
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;
|
_locked = on;
|
||||||
picker.classList.toggle('my-sea-picker--locked', on);
|
picker.classList.toggle('my-sea-picker--locked', on);
|
||||||
// Decks + LOCK HAND go disabled; DEL stays interactive
|
if (delBtn) delBtn.classList.toggle('btn-disabled', !on);
|
||||||
// — it's the user's escape from the locked state
|
if (actionBtn) {
|
||||||
// (opens the guard portal in iter 4b, resets the hand
|
actionBtn.dataset.state = on ? 'gate-view' : 'auto-draw';
|
||||||
// in iter 4a). Without this exemption the SCSS rule
|
actionBtn.innerHTML = on ? 'GATE<br>VIEW' : 'AUTO<br>DRAW';
|
||||||
// `.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);
|
|
||||||
});
|
|
||||||
_hideOk();
|
_hideOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Deck-stack click → show FLIP → click FLIP → deposit ─
|
// ── 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) {
|
picker.querySelectorAll('.sea-deck-stack').forEach(function (stack) {
|
||||||
stack.addEventListener('click', function (e) {
|
stack.addEventListener('click', function (e) {
|
||||||
if (_locked) return;
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_activeStack === stack) _hideOk();
|
if (_activeStack === stack) _hideOk();
|
||||||
else _showOk(stack);
|
else _showOk(stack);
|
||||||
@@ -423,68 +449,149 @@
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
ok.style.display = 'none';
|
ok.style.display = 'none';
|
||||||
ok.addEventListener('click', function (e) {
|
ok.addEventListener('click', function (e) {
|
||||||
if (_locked) return;
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
// Hand complete → FLIP is disabled. No draw.
|
||||||
|
if (_locked) { _hideOk(); return; }
|
||||||
var isLevity = stack.classList.contains('sea-deck-stack--levity');
|
var isLevity = stack.classList.contains('sea-deck-stack--levity');
|
||||||
var pile = isLevity ? _levityPile : _gravityPile;
|
var pile = isLevity ? _levityPile : _gravityPile;
|
||||||
var card = pile.length ? pile.shift() : null;
|
var card = pile.length ? pile.shift() : null;
|
||||||
var order = _currentOrder();
|
var order = _currentOrder();
|
||||||
var posName = order[_filled];
|
var posName = order[_filled];
|
||||||
if (card && posName) {
|
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) {
|
if (window.SeaDeal && window.SeaDeal.openStage) {
|
||||||
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
|
SeaDeal.openStage(card, '.sea-pos-' + posName, isLevity);
|
||||||
} else {
|
} 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);
|
_fillSlot(posName, card, isLevity);
|
||||||
}
|
}
|
||||||
_filled++;
|
_filled++;
|
||||||
// First deposit locks the SPREAD combobox —
|
|
||||||
// switching mid-draw would scramble the
|
|
||||||
// in-progress hand's position mapping.
|
|
||||||
if (_filled === 1) _lockSpread();
|
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();
|
_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.
|
// Click elsewhere inside the picker dismisses the FLIP btn.
|
||||||
picker.addEventListener('click', _hideOk);
|
picker.addEventListener('click', _hideOk);
|
||||||
|
|
||||||
// ── DEL semantics differ by lock state ──────────────────
|
// ── DEL: post-completion only (server-rendered `.btn-
|
||||||
// Pre-lock: DEL resets the in-progress hand client-side
|
// disabled` pre-completion gates pre-hand DEL attempts).
|
||||||
// (iter 4a behaviour — no server round-trip).
|
// Opens the shared `#id_guard_portal` from base.html;
|
||||||
// Post-lock: DEL invokes the shared `#id_guard_portal`
|
// CONFIRM POSTs to /gameboard/my-sea/delete which clears
|
||||||
// from base.html via `window.showGuard`, w. a
|
// the hand but preserves the row (quota stays committed
|
||||||
// "DEL" YES-label override (the room's gear-
|
// for the 24h window). Server returns 204; we reload to
|
||||||
// menu DEL flow uses the same portal). On YES
|
// land on the GATE VIEW landing (FREE DRAW is gone until
|
||||||
// we POST to /gameboard/my-sea/delete then
|
// the row expires).
|
||||||
// navigate back to the page (server returns
|
|
||||||
// 204; we redirect manually to land on the
|
|
||||||
// FREE DRAW landing).
|
|
||||||
if (delBtn) {
|
if (delBtn) {
|
||||||
delBtn.addEventListener('click', function (e) {
|
delBtn.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!_locked) {
|
if (delBtn.classList.contains('btn-disabled')) return;
|
||||||
_resetHand();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.showGuard) 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(
|
window.showGuard(
|
||||||
delBtn,
|
delBtn,
|
||||||
'Are you sure?',
|
'Are you sure?',
|
||||||
@@ -500,29 +607,28 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (lockBtn) {
|
|
||||||
lockBtn.addEventListener('click', function () {
|
// ── Action btn: AUTO DRAW (pre-complete) ↔ GATE VIEW
|
||||||
if (lockBtn.disabled || _locked) return;
|
// (post-complete). Same DOM node, label + behavior keyed
|
||||||
// Collect the in-progress hand for the POST. Slot
|
// on `data-state`. AUTO DRAW opens a guard portal "Auto
|
||||||
// class includes `--levity` / `--gravity`; reversed
|
// deal cards?"; on OK, fills remaining slots client-side
|
||||||
// is on `.sea-card-slot--reversed`. Position comes
|
// + commits to server in one POST. GATE VIEW navigates
|
||||||
// from `data-pos-key`. Card id from `data-card-id`.
|
// to the Sprint-6 gatekeeper (currently 404 stub).
|
||||||
var hand = [];
|
if (actionBtn) {
|
||||||
cross.querySelectorAll(
|
actionBtn.addEventListener('click', function (e) {
|
||||||
'.sea-card-slot.sea-card-slot--filled'
|
e.preventDefault();
|
||||||
).forEach(function (slot) {
|
var state = actionBtn.dataset.state;
|
||||||
var cls = slot.className;
|
if (state === 'gate-view') {
|
||||||
hand.push({
|
window.location.href = actionBtn.dataset.gateUrl || '#';
|
||||||
position: slot.dataset.posKey || '',
|
return;
|
||||||
card_id: parseInt(slot.dataset.cardId, 10),
|
}
|
||||||
reversed: /sea-card-slot--reversed\b/.test(cls),
|
// AUTO DRAW
|
||||||
polarity: /sea-card-slot--levity\b/.test(cls) ? 'levity' : 'gravity',
|
if (!window.showGuard) return;
|
||||||
});
|
window.showGuard(
|
||||||
});
|
actionBtn,
|
||||||
var order = _currentOrder();
|
'Auto deal cards?',
|
||||||
if (hand.length < order.length) return;
|
_autoDraw
|
||||||
_setLocked(true);
|
);
|
||||||
_postLock(hand);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +693,13 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
function _postLock(hand) {
|
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',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -601,9 +713,11 @@
|
|||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
return r.ok ? r.json() : null;
|
return r.ok ? r.json() : null;
|
||||||
}).then(function (body) {
|
}).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);
|
window._showFreeDrawLockedBrief(body.next_free_draw_at);
|
||||||
}
|
}
|
||||||
|
return body;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,20 +737,24 @@
|
|||||||
}
|
}
|
||||||
hidden.addEventListener('change', sync);
|
hidden.addEventListener('change', sync);
|
||||||
|
|
||||||
// Initial state — labels already server-rendered for the
|
// Initial state — iter-4c restoration of partial / complete
|
||||||
// default spread. If the picker was server-rendered w. a
|
// saved-hand sessions. Server pre-renders the slots from
|
||||||
// locked saved hand (iter 4b's active_draw branch), the
|
// saved_by_position; count the filled slots to seed _filled.
|
||||||
// .my-sea-picker--locked class is already on the page —
|
// If hand is complete (server-rendered `.my-sea-picker--
|
||||||
// mirror it in JS state so further interactions stay
|
// locked`), call `_setComplete(true)` to align JS state
|
||||||
// disabled + DEL routes to the guard portal.
|
// (decks click-responsive but FLIP arrives disabled; DEL
|
||||||
_filled = 0;
|
// enabled; action btn = GATE VIEW). Mid-draw saved hands
|
||||||
var _preLocked = picker.classList.contains('my-sea-picker--locked');
|
// just need the count + spread lock (the user resumes
|
||||||
if (_preLocked) {
|
// drawing from where they left off).
|
||||||
_filled = _currentOrder().length;
|
_filled = cross.querySelectorAll(
|
||||||
_setLocked(true);
|
'.sea-card-slot.sea-card-slot--filled'
|
||||||
|
).length;
|
||||||
|
if (_filled >= _currentOrder().length) {
|
||||||
|
_setComplete(true);
|
||||||
|
_lockSpread();
|
||||||
|
} else if (_filled > 0) {
|
||||||
_lockSpread();
|
_lockSpread();
|
||||||
}
|
}
|
||||||
if (lockBtn) lockBtn.disabled = (_filled < _currentOrder().length);
|
|
||||||
|
|
||||||
// Belt-and-braces autofill defense (paired w. autocomplete=
|
// Belt-and-braces autofill defense (paired w. autocomplete=
|
||||||
// off on the hidden input above). Firefox occasionally
|
// off on the hidden input above). Firefox occasionally
|
||||||
@@ -709,6 +827,15 @@
|
|||||||
}, SEAT_ANIM_MS);
|
}, 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
|
// Mirror my-sign's scaleTable() init timing fix — the
|
||||||
// .my-sea-page hasn't flushed its flex sizing on
|
// .my-sea-page hasn't flushed its flex sizing on
|
||||||
// DOMContentLoaded, so the hex stays unscaled until we
|
// DOMContentLoaded, so the hex stays unscaled until we
|
||||||
|
|||||||
Reference in New Issue
Block a user